diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 3582677b..369f8d0f 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,11 +1,11 @@ -name: ❗ SCG Project Bug report -description: Report bug to SCG Project -title: "[BUG] " -labels: ['bug', 'pending'] +name: 🐞 Bug Report +description: Report bug to S-TOP backend +title: "🐞 [BUG] " +labels: ['🐞 Bug'] body: - type: textarea attributes: - label: Description 📖 + label: 📖 Description id: bug-description description: | 발생한 버그에 대해 상세하게 설명해주세요 @@ -13,7 +13,7 @@ body: required: true - type: textarea attributes: - label: Reproduction 🏗️ + label: 🏗 Reproduction id: bug-reproduction description: | 버그 reproduction 방법을 자세히 설명해주세요 @@ -26,23 +26,25 @@ body: required: true - type: textarea attributes: - label: Logs 🪵 + label: 🪵 Logs id: bug-log description: | 버그가 발생했을 때의 Log를 남겨주시면 더욱 더 해결하는 데 도움이 됩니다!🙇 render: shell - type: textarea attributes: - label: Environment 🖥️ + label: 🖥 Environment ️ id: bug-environment description: | 버그가 발생한 환경에 대해 설명해주세요 (브라우저, OS, 하드웨어 등) - type: checkboxes id: bug-checkboxes attributes: - label: CheckList ✔️ + label: ✔️CheckList description: | Issue 올리기 전에 한 번 확인해주세요! 👍 options: - label: 관련 Issue 가 이미 존재하는 지 확인해보셨나요? - required: true \ No newline at end of file + required: true + - label: Assignees 설정 + - label: Labels 설정 \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index af796ba4..601e2224 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,11 +1,11 @@ -name: 🚀 SCG Project New Feature Request -description: Propose new feature to SCG Project -title: "🚀 " -labels: ['enhancement', 'pending'] +name: 🚀 New Feature Request +description: Propose new feature to S-TOP backend +title: "🚀 [FEAT] " +labels: ['🚀 Feature'] body: - type: textarea attributes: - label: Describe Existing Problem ❓ + label: ❓ Describe Existing Problem id: feature-problem description: | 해결하려는 문제를 자세히 설명해주세요! @@ -13,18 +13,21 @@ body: required: true - type: textarea attributes: - label: Describe Solution 💸 + label: ✅ Describe Solution id: feature-solution description: | 위의 문제를 해결하기 위해 어떤 기능을 추가하고 싶은 지 설명해주세요! + value: | + - [ ] task 1 + - [ ] task 2 validations: required: true - type: textarea attributes: - label: Describe Alternatives 💼 - id: feature-solution + label: 📍 More + id: feature-more description: | - 위의 문제를 해결하기 위해 고민한 다른 방법들에 대해서도 자유롭게 서술해주세요! + 기타 참고할만한 사항들을 작성해주세요! - type: checkboxes id: feature-checkboxes attributes: @@ -33,4 +36,6 @@ body: Issue 올리기 전에 한 번 확인해주세요! 👍 options: - label: 관련 Issue 가 이미 존재하는 지 확인해보셨나요? - required: true \ No newline at end of file + required: true + - label: Assignees 설정 + - label: Labels 설정 \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 7334ed2c..3de7d1bd 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,13 +1,13 @@ 작성자: @github_nickname -close/resolve/fix #{이슈 번호 기입} +#{이슈 번호 기입} ## 체크 리스트 - [ ] 적절한 제목으로 수정했나요? - [ ] 상단에 이슈 번호를 기입했나요? - [ ] Target Branch를 올바르게 설정했나요? -- [ ] Label을 알맞게 설정했나요? +- [ ] Reviewers/Assignees/Labels을 알맞게 설정했나요? ## 작업 내역 @@ -15,4 +15,4 @@ close/resolve/fix #{이슈 번호 기입} ## 비고 -- 참고 사항을 적어주세요. 코드 리뷰하는 사람이 참고해야 하는 내용을 자유로운 형식으로 적을 수 있습니다. \ No newline at end of file +- 참고 사항을 적어주세요. 코드 리뷰하는 사람이 참고해야 하는 내용을 자유로운 형식으로 적을 수 있습니다. diff --git a/.github/workflows/CD.yml b/.github/workflows/CD-prod.yml similarity index 96% rename from .github/workflows/CD.yml rename to .github/workflows/CD-prod.yml index b10a6362..c0d95596 100644 --- a/.github/workflows/CD.yml +++ b/.github/workflows/CD-prod.yml @@ -1,11 +1,12 @@ -name: CD +name: CD-PROD on: push: branches: ["main"] + workflow_dispatch: env: dockerimage_tag: ${{ github.sha }} - dockerimage_name: jcy0308/stop + dockerimage_name: jcy0308/stop-be-prod jobs: build: @@ -93,4 +94,4 @@ jobs: git commit -am "Update image tag" git push -u origin main - name: --------------- Clean Up --------------- - run: echo "Clean Up" \ No newline at end of file + run: echo "Clean Up" diff --git a/.github/workflows/CD-qa.yml b/.github/workflows/CD-qa.yml new file mode 100644 index 00000000..62f20a34 --- /dev/null +++ b/.github/workflows/CD-qa.yml @@ -0,0 +1,96 @@ +name: CD + +on: + push: + branches: ["develop"] +env: + dockerimage_tag: ${{ github.sha }} + dockerimage_name: jcy0308/stop-be-qa + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + steps: + - name: --------------- Code Repo --------------- + run: echo "Code Repo" + - name: Code Repo 불러오기 + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'corretto' + + - name: Set up Gradle + uses: gradle/gradle-build-action@v2 + + - name: Gradle 권한 부여 + run: chmod +x gradlew + + - name: Gradle build + run: ./gradlew build --exclude-task test --exclude-task asciidoctor + + - name: Docker 준비(1/4) - 메타데이터 생성 + id: meta + uses: docker/metadata-action@v5.5.1 + with: + images: | + ${{ env.dockerimage_name }} + tags: | + ${{ env.dockerimage_tag }} + latest + flavor: | + latest=true + - name: Docker 준비(2/4) - QEMU 설정 + uses: docker/setup-qemu-action@v3 + - name: Docker 준비(3/4) - buildx 설정 + uses: docker/setup-buildx-action@v3 + - name: Docker 준비(4/4) - 레지스트리 로그인 + uses: docker/login-action@v3.0.0 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCkER_PASSWORD }} + - name: Docker 이미지 빌드+푸시 + id: build-and-push + uses: docker/build-push-action@v5.1.0 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: false + + - name: --------------- Config Repo --------------- + run: echo "[Config Repo]" + - name: Config Repo 불러오기 + uses: actions/checkout@v4 + with: + repository: SystemConsultantGroup/S-TOP-config + ref: main + token: ${{ secrets.ACTION_TOKEN }} + path: S-TOP-config + - name: Kustomize 준비 + uses: imranismail/setup-kustomize@v2.0.0 + - name: Config Repo 이미지 값 업데이트 (Kustomize) + run: | + cd S-TOP-config/overlays/qa/be/ + kustomize edit set image ${{ env.dockerimage_name }}:${{ env.dockerimage_tag }} + cat kustomization.yaml + - name: Config Repo 변경사항 푸시 + run: | + cd S-TOP-config + git config --global user.email "jcy030896@naver.com" + git config --global user.name "chanyeong" + git commit -am "Update image tag" + git push -u origin main + - name: --------------- Clean Up --------------- + run: echo "Clean Up" \ No newline at end of file diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 6f5fc74e..1fee4235 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -2,9 +2,11 @@ name: Java CI with Gradle on: pull_request: - branches: ["main"] + branches: ["main", "develop"] workflow_dispatch: +permissions: write-all + jobs: build: runs-on: ubuntu-latest @@ -35,7 +37,18 @@ jobs: # with: # redis-version: "7.2.5" # redis-port: 6379 + - name: set Config + run: | + echo "${{ secrets.OAUTH_YML }}" | base64 --decode > src/main/resources/application-oauth.yml + echo "${{ secrets.MINIO_YML }}" | base64 --decode > src/main/resources/application-minio.yml + echo "${{ secrets.NOTION_YML }}" | base64 --decode > src/main/resources/application-notion.yml + echo "${{ secrets.EMAIL_YML }}" | base64 --decode > src/main/resources/application-email.yml + echo "${{ secrets.COMMON_YML }}" | base64 --decode > src/main/resources/application-common.yml + find src + - name: gradlew 권한 부여 + run: chmod +x gradlew + - name: Build with Gradle Wrapper run: ./gradlew build @@ -49,9 +62,9 @@ jobs: # min-coverage-overall: 50 # min-coverage-changed-files: 50 # update-comment: true - -# - name: SonarQube test and analyze -# env: -# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -# SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} -# run: ./gradlew test sonar --info --stacktrace \ No newline at end of file + - name: Publish Test Results + uses: mikepenz/action-junit-report@v4.3.1 + if: success() || failure() + with: + report_paths: '**/build/test-results/**/*.xml' + token: ${{secrets.JUNIT_TOKEN}} diff --git a/.gitignore b/.gitignore index c2065bc2..4a44c233 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,11 @@ out/ ### VS Code ### .vscode/ + +/src/main/resources/application-oauth.yml +/src/main/resources/application-minio.yml +/src/main/resources/application-dev.yml +/src/main/resources/application-email.yml +/src/main/resources/application-notion.yml + +.DS_Store diff --git a/README.md b/README.md index 9042fd6c..468dbefc 100644 --- a/README.md +++ b/README.md @@ -1 +1,3 @@ -# S-TOP-backend \ No newline at end of file +# S-TOP backend + +[S-TOP Rest Docs](https://systemconsultantgroup.github.io/S-top-backend/src/main/resources/static/docs/index.html) diff --git a/build.gradle b/build.gradle index 4672fb53..c4a06ed0 100644 --- a/build.gradle +++ b/build.gradle @@ -35,11 +35,13 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-aop' + implementation 'io.hypersistence:hypersistence-utils-hibernate-60:3.8.1' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' testImplementation 'org.springframework.boot:spring-boot-starter-test' + developmentOnly 'org.springframework.boot:spring-boot-devtools' //auth implementation 'io.jsonwebtoken:jjwt-api:0.11.5' @@ -54,9 +56,33 @@ dependencies { //metrics implementation 'io.micrometer:micrometer-registry-prometheus' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + //email + implementation 'org.springframework.boot:spring-boot-starter-mail' + + //thymeleaf(for email service) + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // apache.poi for excel + implementation 'org.apache.poi:poi-ooxml:5.2.2' + implementation 'org.apache.poi:poi:5.2.2' + + // minIO + implementation "io.minio:minio:8.5.11" + + // queryDSL + implementation 'com.querydsl:querydsl-core' + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor("com.querydsl:querydsl-apt:5.0.0:jakarta") // querydsl JPAAnnotationProcessor 사용 지정 + annotationProcessor("jakarta.persistence:jakarta.persistence-api") // java.lang.NoClassDefFoundError(javax.annotation.Entity) 발생 대응 + annotationProcessor("jakarta.annotation:jakarta.annotation-api") // java.lang.NoClassDefFoundError (javax.annotation.Generated) 발생 대응 } + + tasks.named('test') { outputs.dir snippetsDir useJUnitPlatform() @@ -87,8 +113,13 @@ tasks.named('asciidoctor') { build { dependsOn copyDocument } + allprojects { repositories { maven { url 'https://jitpack.io'} } -} \ No newline at end of file +} + + +def querydslDir = "build/generated/querydsl" + diff --git a/src/docs/asciidoc/aihub-controller-test.adoc b/src/docs/asciidoc/aihub-controller-test.adoc new file mode 100644 index 00000000..805667d9 --- /dev/null +++ b/src/docs/asciidoc/aihub-controller-test.adoc @@ -0,0 +1,16 @@ +== AI HUB API +:source-highlighter: highlightjs + +--- + +=== AI HUB 모델 리스트 조회 (POST /aihub/models) + +==== +operation::ai-hub-controller-test/get-ai-hub-models[snippets="query-parameters,request-fields,http-request,http-response,response-fields"] +==== + +=== AI HUB 데이터셋 리스트 조회 (POST /aihub/datasets) + +==== +operation::ai-hub-controller-test/get-ai-hub-datasets[snippets="query-parameters,request-fields,http-request,http-response,response-fields"] +==== diff --git a/src/docs/asciidoc/application.adoc b/src/docs/asciidoc/application.adoc new file mode 100644 index 00000000..da9fb78b --- /dev/null +++ b/src/docs/asciidoc/application.adoc @@ -0,0 +1,23 @@ +== 가입 신청 관리 API +:source-highlighter: highlightjs + +--- +=== 가입 신청 리스트 조회 (GET /applications) +==== +operation::application-controller-test/get-applications[snippets="http-request,query-parameters,http-response,response-fields"] +==== + +=== 가입 신청자 상세 정보 조회 (GET /applications/{applicationId}) +==== +operation::application-controller-test/get-application[snippets="http-request,path-parameters,http-response,response-fields"] +==== + +=== 교수/기업 가입 허가 (PATCH /applications/{applicationId}) +==== +operation::application-controller-test/update-application[snippets="http-request,path-parameters,http-response,response-fields"] +==== + +=== 교수/기업 가입 거절 (DELETE /applications/{applicationId}) +==== +operation::application-controller-test/reject-application[snippets="http-request,path-parameters,http-response"] +==== \ No newline at end of file diff --git a/src/docs/asciidoc/auth-controller-test.adoc b/src/docs/asciidoc/auth-controller-test.adoc new file mode 100644 index 00000000..9e9821d6 --- /dev/null +++ b/src/docs/asciidoc/auth-controller-test.adoc @@ -0,0 +1,21 @@ +== 인증 API +:source-highlighter: highlightjs + +--- + +=== 카카오 소셜 로그인 (POST /auth/login/kakao/) +==== +operation::auth-controller-test/kakao-social-login[snippets="http-request,query-parameters,http-response,response-fields"] +==== +=== 회원가입 (POST /auth/register) +==== +operation::auth-controller-test/register[snippets="http-request,request-cookies,request-headers,request-fields,http-response,response-fields"] +==== +=== Access Token 재발급 (POST /auth/reissue) +==== +operation::auth-controller-test/reissue-token[snippets="http-request,http-response"] +==== +=== 로그아웃 (POST /auth/logout) +==== +operation::auth-controller-test/logout[snippets="http-request,http-response"] +==== \ No newline at end of file diff --git a/src/docs/asciidoc/department.adoc b/src/docs/asciidoc/department.adoc new file mode 100644 index 00000000..7d32b14c --- /dev/null +++ b/src/docs/asciidoc/department.adoc @@ -0,0 +1,9 @@ +== 학과 API +:source-highlighter: highlightjs + +--- + +=== 학과 리스트 조회 (GET /departments) +==== +operation::department-controller-test/get-departments[snippets="http-request,http-response,response-fields"] +==== \ No newline at end of file diff --git a/src/docs/asciidoc/eventNotice.adoc b/src/docs/asciidoc/eventNotice.adoc new file mode 100644 index 00000000..ca0689f8 --- /dev/null +++ b/src/docs/asciidoc/eventNotice.adoc @@ -0,0 +1,32 @@ +== 이벤트 공지 사항 API +:source-highlighter: highlightjs + +=== 이벤트 공지 사항 생성 (POST /eventNotices) + +==== +operation::event-notice-controller-test/create-event-notice[snippets="http-request,request-fields,http-response,response-fields"] +==== + +=== 이벤트 공지 사항 리스트 조회 (GET /eventNotices) + +==== +operation::event-notice-controller-test/get-event-notice-list[snippets="query-parameters,http-request,http-response,response-fields"] +==== + +=== 이벤트 공지 사항 조회 (GET /eventNotices/{eventNoticeId}) + +==== +operation::event-notice-controller-test/get-event-notice[snippets="path-parameters,http-request,http-response,response-fields"] +==== + +=== 이벤트 공지 사항 수정 (PUT /eventNotices/{eventNoticeId}) + +==== +operation::event-notice-controller-test/update-event-notice[snippets="path-parameters,http-request,request-fields,http-response,response-fields"] +==== + +=== 이벤트 공지 사항 삭제 (DELETE /eventNotices/{eventNoticeId}) + +==== +operation::event-notice-controller-test/delete-event-notice[snippets="path-parameters,http-request,http-response"] +==== diff --git a/src/docs/asciidoc/eventPeriod.adoc b/src/docs/asciidoc/eventPeriod.adoc new file mode 100644 index 00000000..4db4f69a --- /dev/null +++ b/src/docs/asciidoc/eventPeriod.adoc @@ -0,0 +1,23 @@ +== 이벤트 기간 API +:source-highlighter: highlightjs + +--- +=== 이벤트 기간 생성 (POST /eventPeriods) +==== +operation::event-period-controller-test/create-event-period[snippets="http-request,request-fields,http-response,response-fields"] +==== + +=== 올해의 이벤트 기간 조회 (GET /eventPeriod) +==== +operation::event-period-controller-test/get-event-period[snippets="http-request,http-response,response-fields"] +==== + +=== 전체 이벤트 기간 리스트 조회 (GET /eventPeriods) +==== +operation::event-period-controller-test/get-event-periods[snippets="http-request,http-response,response-fields"] +==== + +=== 올해의 이벤트 기간 업데이트 (PUT /eventPeriod) +==== +operation::event-period-controller-test/update-event-period[snippets="http-request,request-fields,http-response,response-fields"] +==== diff --git a/src/docs/asciidoc/file-controller-test.adoc b/src/docs/asciidoc/file-controller-test.adoc new file mode 100644 index 00000000..92ca466a --- /dev/null +++ b/src/docs/asciidoc/file-controller-test.adoc @@ -0,0 +1,22 @@ +== 파일 API +:source-highlighter: highlightjs + +--- + +=== 다중 파일 업로드 (POST /files) + +==== +operation::file-controller-test/upload-files[snippets="http-request,request-parts,http-response,response-fields"] +==== + +=== 파일 조회 (GET /files/{fileId}) + +==== +operation::file-controller-test/get-file[snippets="http-request,path-parameters,http-response"] +==== + +=== 프로젝트 일괄 등록 양식 다운로드 (GET /files/form/projects) + +==== +operation::file-controller-test/get-project-excel-form[snippets="http-request,http-response"] +==== diff --git a/src/docs/asciidoc/gallery.adoc b/src/docs/asciidoc/gallery.adoc new file mode 100644 index 00000000..fbc33165 --- /dev/null +++ b/src/docs/asciidoc/gallery.adoc @@ -0,0 +1,28 @@ +== 갤러리 API +:source-highlighter: highlightjs + +--- +=== 갤러리 게시글 생성 (POST /galleries) +==== +operation::gallery-controller-test/create-gallery[snippets="http-request,request-fields,http-response,response-fields"] +==== + +=== 갤러리 게시글 목록 조회 (GET /galleries) +==== +operation::gallery-controller-test/get-galleries[snippets="http-request,query-parameters,http-response,response-fields"] +==== + +=== 갤러리 게시글 조회 (GET /galleries/{galleryId}) +==== +operation::gallery-controller-test/get-gallery[snippets="http-request,path-parameters,http-response,response-fields"] +==== + +=== 갤러리 게시글 삭제 (DELETE /galleries/{galleryId}) +==== +operation::gallery-controller-test/delete-gallery[snippets="http-request,path-parameters"] +==== + +=== 갤러리 게시글 수정 (PUT /galleries/{galleryId}) +==== +operation::gallery-controller-test/update-gallery[snippets="http-request,path-parameters,request-fields,http-response,response-fields"] +==== \ No newline at end of file diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc new file mode 100644 index 00000000..0a64e1cf --- /dev/null +++ b/src/docs/asciidoc/index.adoc @@ -0,0 +1,27 @@ += S-TOP Rest Docs +:doctype: book +:source-highlighter: highlightjs +:toc: left +:toclevels: 2 +:seclinks: + +=== 커스텀 예외 코드 +include::{snippets}/exception-code-controller-test/get-exception-codes/exception-response-fields.adoc[] + +include::auth-controller-test.adoc[] +include::file-controller-test.adoc[] +include::user.adoc[] +include::department.adoc[] +include::application.adoc[] +include::jobInterview.adoc[] +include::talk.adoc[] +include::quiz.adoc[] +include::notice.adoc[] +include::eventNotice.adoc[] +include::eventPeriod.adoc[] +include::gallery.adoc[] +include::project.adoc[] +include::inquiry.adoc[] +include::proposal.adoc[] +include::aihub-controller-test.adoc[] +include::jobInfo-controller-test.adoc[] diff --git a/src/docs/asciidoc/inquiry.adoc b/src/docs/asciidoc/inquiry.adoc new file mode 100644 index 00000000..a7025318 --- /dev/null +++ b/src/docs/asciidoc/inquiry.adoc @@ -0,0 +1,73 @@ +== 프로젝트 문의 사항 API +:source-highlighter: highlightjs + +--- +=== 프로젝트 문의 사항 생성 (POST /projects/{projectId}/inquiry) + +==== +operation::inquiry-controller-test/create-inquiry[snippets="http-request,request-fields,http-response,response-fields"] +==== + +--- + +=== 프로젝트 문의 사항 리스트 조회 (GET /inquiries) + +==== +operation::inquiry-controller-test/get-inquiries[snippets="http-request,query-parameters,http-response,response-fields"] +==== + +--- + +=== 프로젝트 문의 사항 단건 조회 (GET /inquiries/{inquiryId}) + +==== +operation::inquiry-controller-test/get-inquiry[snippets="http-request,path-parameters,http-response,response-fields"] +==== + +--- + +=== 프로젝트 문의 사항 수정 (PUT /inquiries/{inquiryId}) + +==== +operation::inquiry-controller-test/update-inquiry[snippets="http-request,path-parameters,request-fields,http-response,response-fields"] +==== + +--- + +=== 프로젝트 문의 사항 삭제 (DELETE /inquiries/{inquiryId}) + +==== +operation::inquiry-controller-test/delete-inquiry[snippets="http-request,path-parameters,http-response"] +==== + +--- + +=== 프로젝트 문의 사항 답변 (POST /inquiries/{inquiryId}/reply) + +==== +operation::inquiry-controller-test/create-inquiry-reply[snippets="http-request,path-parameters,request-fields,http-response,response-fields"] +==== + +--- + +=== 프로젝트 문의 사항 답변 조회 (GET /inquiries/{inquiryId}/reply) + +==== +operation::inquiry-controller-test/get-inquiry-reply[snippets="http-request,path-parameters,http-response,response-fields"] +==== + +--- + +=== 프로젝트 문의 사항 답변 수정 (PUT /inquiries/{inquiryId}/reply) + +==== +operation::inquiry-controller-test/update-inquiry-reply[snippets="http-request,path-parameters,request-fields,http-response,response-fields"] +==== + +--- + +=== 프로젝트 문의 사항 답변 삭제 (DELETE /inquiries/{inquiryId}/reply) + +==== +operation::inquiry-controller-test/delete-inquiry-reply[snippets="http-request,path-parameters,http-response"] +==== diff --git a/src/docs/asciidoc/jobInfo-controller-test.adoc b/src/docs/asciidoc/jobInfo-controller-test.adoc new file mode 100644 index 00000000..c5a33d16 --- /dev/null +++ b/src/docs/asciidoc/jobInfo-controller-test.adoc @@ -0,0 +1,10 @@ +== JOB INFO API +:source-highlighter: highlightjs + +--- + +=== JOB INFO 리스트 조회 (POST /jobInfos) + +==== +operation::job-info-controller-test/get-job-infos[snippets="query-parameters,request-fields,http-request,http-response,response-fields"] +==== \ No newline at end of file diff --git a/src/docs/asciidoc/jobInterview.adoc b/src/docs/asciidoc/jobInterview.adoc new file mode 100644 index 00000000..fcbb68a1 --- /dev/null +++ b/src/docs/asciidoc/jobInterview.adoc @@ -0,0 +1,38 @@ +== 잡페어 인터뷰 API +:source-highlighter: highlightjs + +--- +=== 잡페어 인터뷰 생성 (POST /jobInterviews) +==== +operation::job-interview-controller-test/create-job-interview[snippets="http-request,request-fields,http-response,response-fields"] +==== + +=== 잡페어 인터뷰 리스트 조회 (GET /jobInterviews) +==== +operation::job-interview-controller-test/get-job-interview-list[snippets="http-request,query-parameters,http-response,response-fields"] +==== + +=== 잡페어 인터뷰 단건 조회 (GET /jobInterviews/{jobInterviewId}) +==== +operation::job-interview-controller-test/get-job-interview[snippets="http-request,path-parameters,http-response,response-fields"] +==== + +=== 잡페어 인터뷰 수정 (PUT /jobInterviews/{jobInterviewId}) +==== +operation::job-interview-controller-test/update-job-interview[snippets="http-request,path-parameters,request-fields,http-response,response-fields"] +==== + +=== 잡페어 인터뷰 삭제 (DELETE /jobInterviews/{jobInterviewId}) +==== +operation::job-interview-controller-test/delete-job-interview[snippets="http-request,path-parameters,http-response"] +==== + +=== 잡페어 인터뷰 관심 등록 (POST /jobInterviews/{jobInterviews}/favorite) +==== +operation::job-interview-controller-test/create-job-interview-favorite[snippets="http-request,path-parameters,http-response"] +==== + +=== 잡페어 인터뷰 관심 삭제 (DELETE /jobInterviews/{jobInterviews}/favorite) +==== +operation::job-interview-controller-test/delete-job-interview-favorite[snippets="http-request,path-parameters,http-response"] +==== diff --git a/src/docs/asciidoc/notice.adoc b/src/docs/asciidoc/notice.adoc new file mode 100644 index 00000000..1f0b0f54 --- /dev/null +++ b/src/docs/asciidoc/notice.adoc @@ -0,0 +1,32 @@ +== 공지 사항 API +:source-highlighter: highlightjs + +=== 공지 사항 생성 (POST /notices) + +==== +operation::notice-controller-test/create-notice[snippets="http-request,request-fields,http-response,response-fields"] +==== + +=== 공지 사항 리스트 조회 (GET /notices) + +==== +operation::notice-controller-test/get-notice-list[snippets="query-parameters,http-request,http-response,response-fields"] +==== + +=== 공지 사항 조회 (GET /notices/{noticeId}) + +==== +operation::notice-controller-test/get-notice[snippets="path-parameters,http-request,http-response,response-fields"] +==== + +=== 공지 사항 수정 (PUT /notices/{noticeId}) + +==== +operation::notice-controller-test/update-notice[snippets="path-parameters,http-request,request-fields,http-response,response-fields"] +==== + +=== 공지 사항 삭제 (DELETE /notices/{noticeId}) + +==== +operation::notice-controller-test/delete-notice[snippets="path-parameters,http-request,http-response"] +==== diff --git a/src/docs/asciidoc/project.adoc b/src/docs/asciidoc/project.adoc new file mode 100644 index 00000000..d4767b92 --- /dev/null +++ b/src/docs/asciidoc/project.adoc @@ -0,0 +1,81 @@ +== 프로젝트 API +:source-highlighter: highlightjs + +--- +=== 프로젝트 조회 (GET /projects) + +==== +operation::project-controller-test/get-projects[snippets="http-request,http-response,query-parameters,response-fields"] +==== + +=== 프로젝트 생성 (POST /projects) + +==== +operation::project-controller-test/create-project[snippets="http-request,http-response,request-fields,response-fields"] +==== + +=== 프로젝트 엑셀 일괄등록 (POST /projects/excel) + +==== +operation::project-excel-controller-test/create-project-excel[snippets="http-request,request-parts,http-response,response-fields"] +==== + +=== 프로젝트 조회 (GET /projects/{projectId}) + +==== +operation::project-controller-test/get-project[snippets="http-request,http-response,path-parameters,response-fields"] +==== + +=== 프로젝트 수정 (PUT /projects/{projectId}) + +==== +operation::project-controller-test/update-project[snippets="http-request,http-response,path-parameters,request-fields,response-fields"] +==== + +=== 프로젝트 삭제 (DELETE /projects/{projectId}) + +==== +operation::project-controller-test/delete-project[snippets="http-request,http-response,path-parameters"] +==== + +=== 관심 프로젝트 등록 (POST /projects/{projectId}/favorite) + +==== +operation::project-controller-test/create-project-favorite[snippets="http-request,http-response,path-parameters"] +==== + +=== 관심 프로젝트 삭제 (DELETE /projects/{projectId}/favorite) + +==== +operation::project-controller-test/delete-project-favorite[snippets="http-request,http-response,path-parameters"] +==== + +=== 프로젝트 좋아요 등록 (POST /projects/{projectId}/like) + +==== +operation::project-controller-test/create-project-like[snippets="http-request,http-response,path-parameters"] +==== + +=== 프로젝트 좋아요 삭제 (DELETE /projects/{projectId}/like) + +==== +operation::project-controller-test/delete-project-like[snippets="http-request,http-response,path-parameters"] +==== + +=== 프로젝트 댓글 등록 (POST /projects/{projectId}/comment) + +==== +operation::project-controller-test/create-project-comment[snippets="http-request,http-response,path-parameters,request-fields,response-fields"] +==== + +=== 프로젝트 댓글 삭제 (DELETE /projects/{projectId}/comment) + +==== +operation::project-controller-test/delete-project-comment[snippets="http-request,http-response,path-parameters"] +==== + +=== 수상 프로젝트 조회 (GET /projects/award?year={year}) + +==== +operation::project-controller-test/get-award-projects[snippets="http-request,http-response,query-parameters,response-fields"] +==== diff --git a/src/docs/asciidoc/proposal.adoc b/src/docs/asciidoc/proposal.adoc new file mode 100644 index 00000000..3049ff42 --- /dev/null +++ b/src/docs/asciidoc/proposal.adoc @@ -0,0 +1,56 @@ +== 과제 제안 API +:source-highlighter: highlightjs + +=== 과제 제안 생성 (POST /proposals) + +==== +operation::proposal-controller-test/create-proposal[snippets="http-request,request-fields,http-response,response-fields"] +==== + +=== 과제 제안 리스트 조회 (GET /proposals) + +==== +operation::proposal-controller-test/get-proposals[snippets="query-parameters,http-request,http-response,response-fields"] +==== + +=== 과제 제안 상세 조회 (GET /proposals/{proposalId}) + +==== +operation::proposal-controller-test/get-proposal-detail[snippets="path-parameters,http-request,http-response,response-fields"] +==== + +=== 과제 제안 수정 (PUT /proposals/{proposalId}) + +==== +operation::proposal-controller-test/update-proposal[snippets="path-parameters,http-request,request-fields,http-response,response-fields"] +==== + +=== 과제 제안 삭제 (DELETE /proposals/{proposalId}) + +==== +operation::proposal-controller-test/delete-proposal[snippets="path-parameters,http-request,http-response"] +==== + +=== 과제 제안 답변 조회 (GET /proposals/{proposalId}/reply) + +==== +operation::proposal-controller-test/get-proposal-replies[snippets="query-parameters,http-request,http-response,response-fields"] +==== + +=== 과제 제안 답변 생성 (PUT /proposals/{proposalId}/reply/) + +==== +operation::proposal-controller-test/create-proposal-reply[snippets="path-parameters,http-request,request-fields,http-response,response-fields"] +==== + +=== 과제 제안 답변 수정 (PUT /proposals/{proposalId}/reply/{replyId}) + +==== +operation::proposal-controller-test/update-proposal-reply[snippets="path-parameters,http-request,request-fields,http-response,response-fields"] +==== + +=== 과제 제안 답변 삭제 (DELETE /proposals/{proposalId}/reply/{replyId}) + +==== +operation::proposal-controller-test/delete-proposal-reply[snippets="path-parameters,http-request,http-response"] +==== \ No newline at end of file diff --git a/src/docs/asciidoc/quiz.adoc b/src/docs/asciidoc/quiz.adoc new file mode 100644 index 00000000..f52189a8 --- /dev/null +++ b/src/docs/asciidoc/quiz.adoc @@ -0,0 +1,13 @@ +== 퀴즈 결과 API +:source-highlighter: highlightjs + +--- +=== 퀴즈 결과 조회 (GET /quizzes/result) +==== +operation::quiz-controller-test/get-quiz-results[snippets="http-request,query-parameters,http-response,response-fields"] +==== + +=== 퀴즈 결과를 엑셀로 받기 (GET /quizzes/result/excel) +==== +operation::quiz-controller-test/get-quiz-results-to-excel[snippets="http-request,query-parameters,http-response"] +==== diff --git a/src/docs/asciidoc/talk.adoc b/src/docs/asciidoc/talk.adoc new file mode 100644 index 00000000..b1be28ae --- /dev/null +++ b/src/docs/asciidoc/talk.adoc @@ -0,0 +1,53 @@ +== 대담 영상 API +:source-highlighter: highlightjs + +--- +=== 대담 영상 생성 (POST /talks) +==== +operation::talk-controller-test/create-talk[snippets="http-request,request-fields,http-response,response-fields"] +==== + +=== 대담 영상 리스트 조회 (GET /talks) +==== +operation::talk-controller-test/get-talk-list[snippets="http-request,query-parameters,http-response,response-fields"] +==== + +=== 대담 영상 단건 조회 (GET /talks/{talkId}) +==== +operation::talk-controller-test/get-talk[snippets="http-request,path-parameters,http-response,response-fields"] +==== + +=== 대담 영상 수정 (PUT /talks/{talkId}) +==== +operation::talk-controller-test/update-talk[snippets="http-request,path-parameters,request-fields,http-response,response-fields"] +==== + +=== 대담 영상 삭제 (DELETE /talks/{talkId}) +==== +operation::talk-controller-test/delete-talk[snippets="http-request,path-parameters,http-response"] +==== + +=== 대담 영상의 퀴즈 조회 (GET /talks/{talkId}/quiz) +==== +operation::talk-controller-test/get-quiz[snippets="http-request,path-parameters,http-response,response-fields"] +==== + +=== 대담 영상의 퀴즈 결과 제출 (POST /talks/{talkId}/quiz) +==== +operation::talk-controller-test/submit-quiz[snippets="http-request,path-parameters,request-fields,http-response,response-fields"] +==== + +=== 대담 영상의 관심 등록 (POST /talks/{talkId}/favorite) +==== +operation::talk-controller-test/create-talk-favorite[snippets="http-request,path-parameters,http-response"] +==== + +=== 대담 영상의 관심 삭제 (DELETE /talks/{talkId}/favorite) +==== +operation::talk-controller-test/delete-talk-favorite[snippets="http-request,path-parameters,http-response"] +==== + +=== 유저의 퀴즈 제출 기록 조회 (GET /talks/{talkId/quiz/submit) +==== +operation::talk-controller-test/get-user-quiz[snippets="http-request,path-parameters,http-response,response-fields"] +==== \ No newline at end of file diff --git a/src/docs/asciidoc/user.adoc b/src/docs/asciidoc/user.adoc new file mode 100644 index 00000000..28302fa0 --- /dev/null +++ b/src/docs/asciidoc/user.adoc @@ -0,0 +1,44 @@ +== 유저 API +:source-highlighter: highlightjs + +--- + +=== 로그인 유저 기본 정보 조회 (GET /users/me) +==== +operation::user-controller-test/get-me[snippets="http-request,http-response,response-fields"] +==== + +=== 로그인 유저 기본 정보 수정 (PUT /users/me) +==== +operation::user-controller-test/update-me[snippets="http-request,request-fields,http-response,response-fields"] +==== + +=== 유저 탈퇴 (DELETE /users/me) +==== +operation::user-controller-test/delete-me[snippets="http-request,http-response"] +==== + +=== 유저 관심 프로젝트 리스트 조회 (GET /users/favorites/projects) +==== +operation::user-controller-test/get-user-favorite-projects[snippets="http-request,http-response,response-fields"] +==== + +=== 유저 관심 대담영상 리스트 조회 (GET /users/favorites/talks) +==== +operation::user-controller-test/get-user-favorite-talks[snippets="http-request,http-response,response-fields"] +==== + +=== 유저 관심 잡페어인터뷰영상 리스트 조회 (GET /users/favorites/jobInterviews) +==== +operation::user-controller-test/get-user-favorite-interviews[snippets="http-request,http-response,response-fields"] +==== + +=== 유저 문의 리스트 조회 (GET /users/inquiries) +==== +operation::user-controller-test/get-user-inquiries[snippets="http-request,http-response,response-fields"] +==== + +=== 유저 과제 제안 리스트 조회 (GET /users/proposals) +==== +operation::user-controller-test/get-user-proposals[snippets="http-request,http-response,response-fields"] +==== diff --git a/src/main/java/com/scg/stop/StopApplication.java b/src/main/java/com/scg/stop/StopApplication.java index 09573bb5..352a63aa 100644 --- a/src/main/java/com/scg/stop/StopApplication.java +++ b/src/main/java/com/scg/stop/StopApplication.java @@ -6,8 +6,8 @@ @SpringBootApplication public class StopApplication { - public static void main(String[] args) { - SpringApplication.run(StopApplication.class, args); - } + public static void main(String[] args) { + SpringApplication.run(StopApplication.class, args); + } } diff --git a/src/main/java/com/scg/stop/aihub/controller/AiHubController.java b/src/main/java/com/scg/stop/aihub/controller/AiHubController.java new file mode 100644 index 00000000..56c26875 --- /dev/null +++ b/src/main/java/com/scg/stop/aihub/controller/AiHubController.java @@ -0,0 +1,43 @@ +package com.scg.stop.aihub.controller; + +import com.scg.stop.aihub.dto.request.AiHubDatasetRequest; +import com.scg.stop.aihub.dto.request.AiHubModelRequest; +import com.scg.stop.aihub.dto.response.AiHubDatasetResponse; +import com.scg.stop.aihub.dto.response.AiHubModelResponse; +import com.scg.stop.aihub.service.AiHubService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +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("/aihub") +@RequiredArgsConstructor +public class AiHubController { + + private final AiHubService aiHubService; + + // Get the paginated list of models + @PostMapping("/models") + public ResponseEntity> getAiHubModels( + @RequestBody AiHubModelRequest request, + @PageableDefault(page = 0, size = 10) Pageable pageable) { + Page models = aiHubService.getAiHubModels(request, pageable); + return ResponseEntity.status(HttpStatus.OK).body(models); + } + + // Get the paginated list of datasets + @PostMapping("/datasets") + public ResponseEntity> getAiHubDatasets( + @RequestBody AiHubDatasetRequest request, + @PageableDefault(page = 0, size = 10) Pageable pageable) { + Page datasets = aiHubService.getAiHubDatasets(request, pageable); + return ResponseEntity.status(HttpStatus.OK).body(datasets); + } +} diff --git a/src/main/java/com/scg/stop/aihub/dto/request/AiHubDatasetRequest.java b/src/main/java/com/scg/stop/aihub/dto/request/AiHubDatasetRequest.java new file mode 100644 index 00000000..ba9b6d91 --- /dev/null +++ b/src/main/java/com/scg/stop/aihub/dto/request/AiHubDatasetRequest.java @@ -0,0 +1,21 @@ +package com.scg.stop.aihub.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class AiHubDatasetRequest { + + private String title; // 제목 : text + private List dataTypes; // 데이터 유형 : multiselect + private List topics; // 주제 분류 : multiselect + private List developmentYears; // 구축 년도 : multiselect + private String professor; // 담당 교수 : text + private List participants; // 참여 학생 : text (comma separated) +} + diff --git a/src/main/java/com/scg/stop/aihub/dto/request/AiHubModelRequest.java b/src/main/java/com/scg/stop/aihub/dto/request/AiHubModelRequest.java new file mode 100644 index 00000000..42257f49 --- /dev/null +++ b/src/main/java/com/scg/stop/aihub/dto/request/AiHubModelRequest.java @@ -0,0 +1,21 @@ +package com.scg.stop.aihub.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class AiHubModelRequest { + + private String title; // 제목 : text + private List learningModels; // 학습 모델 : multiselect + private List topics; // 주제 분류 : multiselect + private List developmentYears; // 개발 년도 : multiselect + private String professor; // 담당 교수 : text + private List participants; // 참여 학생 : text (comma separated) +} + diff --git a/src/main/java/com/scg/stop/aihub/dto/response/AiHubDatasetResponse.java b/src/main/java/com/scg/stop/aihub/dto/response/AiHubDatasetResponse.java new file mode 100644 index 00000000..827af58a --- /dev/null +++ b/src/main/java/com/scg/stop/aihub/dto/response/AiHubDatasetResponse.java @@ -0,0 +1,115 @@ +package com.scg.stop.aihub.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class AiHubDatasetResponse { + private String object; + private String id; // 페이지 id + private String cover; // 커버 이미지 + private String title; // 제목 + private List dataTypes = new ArrayList<>(); // 데이터 유형 + private List topics = new ArrayList<>(); // 주제 분류 + private List developmentYears = new ArrayList<>(); // 구축 년도 + private String professor; // 담당 교수 + private List participants = new ArrayList<>(); // 참여 학생 + private String url; // redirect url + + @JsonProperty("object") + public void setObject(String object) { + this.object = object; + } + + @JsonProperty("id") + public void setId(String id) { + this.id = id; + } + + @JsonProperty("cover") + public void setCover(JsonNode cover) { + if (cover != null) { + if (cover.has("external")) { // external cover image + this.cover = cover.get("external").get("url").asText(); + } else if (cover.has("file")) { // file cover image + this.cover = cover.get("file").get("url").asText(); + } + } + } + + // Extract properties from the JSON response and set the fields of response dto + @JsonProperty("properties") + public void setProperties(JsonNode properties) { + if (properties != null) { + this.title = extractTextFromProperty(properties, "제목", "title"); + this.dataTypes = extractMultiSelect(properties, "데이터 유형"); + this.topics = extractMultiSelect(properties, "주제 분류"); + this.developmentYears = extractIntegerMultiSelect(properties, "구축 년도"); + this.professor = extractTextFromProperty(properties, "담당 교수", "rich_text"); + this.participants = extractParticipants(properties); + } + } + + @JsonProperty("url") + public void setUrl(String url) { + this.url = url; + } + + // Helper methods + // Extract text from the text property + private String extractTextFromProperty(JsonNode properties, String fieldName, String textType) { + if (properties.has(fieldName)) { + JsonNode fieldNode = properties.get(fieldName).get(textType); + if (fieldNode.isArray() && fieldNode.size() > 0) { + return fieldNode.get(0).get("text").get("content").asText(); + } + } + return null; + } + + // Extract participants from the comma-separated text property + private List extractParticipants(JsonNode properties) { + String participantsString = extractTextFromProperty(properties, "참여 학생", "rich_text"); + if (participantsString != null && !participantsString.isEmpty()) { + return Arrays.asList(participantsString.split("\\s*,\\s*")); + } + return new ArrayList<>(); + } + + // Extract multi-select property + private List extractMultiSelect(JsonNode properties, String fieldName) { + List result = new ArrayList<>(); + if (properties.has(fieldName)) { + JsonNode multiSelectNode = properties.get(fieldName).get("multi_select"); + if (multiSelectNode.isArray()) { + multiSelectNode.forEach(node -> result.add(node.get("name").asText())); + } + } + return result; + } + + // Extract multi-select property as integers + private List extractIntegerMultiSelect(JsonNode properties, String fieldName) { + List result = new ArrayList<>(); + if (properties.has(fieldName)) { + JsonNode multiSelectNode = properties.get(fieldName).get("multi_select"); + if (multiSelectNode.isArray()) { + multiSelectNode.forEach(node -> result.add(Integer.parseInt(node.get("name").asText()))); + } + } + return result; + } +} diff --git a/src/main/java/com/scg/stop/aihub/dto/response/AiHubModelResponse.java b/src/main/java/com/scg/stop/aihub/dto/response/AiHubModelResponse.java new file mode 100644 index 00000000..22e909e9 --- /dev/null +++ b/src/main/java/com/scg/stop/aihub/dto/response/AiHubModelResponse.java @@ -0,0 +1,115 @@ +package com.scg.stop.aihub.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class AiHubModelResponse { + private String object; + private String id; // 페이지 id + private String cover; // 커버 이미지 + private String title; // 제목 + private List learningModels = new ArrayList<>(); // 학습 모델 + private List topics = new ArrayList<>(); // 주제 분류 + private List developmentYears = new ArrayList<>(); // 개발 년도 (now List) + private String professor; // 담당 교수 + private List participants = new ArrayList<>(); // 참여 학생 + private String url; + + @JsonProperty("object") + public void setObject(String object) { + this.object = object; + } + + @JsonProperty("id") + public void setId(String id) { + this.id = id; + } + + @JsonProperty("cover") + public void setCover(JsonNode cover) { + if (cover != null) { + if (cover.has("external")) { // external cover image + this.cover = cover.get("external").get("url").asText(); + } else if (cover.has("file")) { // file cover image + this.cover = cover.get("file").get("url").asText(); + } + } + } + + // Extract properties from the JSON response and set the fields of response dto + @JsonProperty("properties") + public void setProperties(JsonNode properties) { + if (properties != null) { + this.title = extractTextFromProperty(properties, "제목", "title"); + this.learningModels = extractMultiSelect(properties, "학습 모델"); + this.topics = extractMultiSelect(properties, "주제 분류"); + this.developmentYears = extractIntegerMultiSelect(properties, "개발 년도"); + this.professor = extractTextFromProperty(properties, "담당 교수", "rich_text"); + this.participants = extractParticipants(properties); + } + } + + @JsonProperty("url") + public void setUrl(String url) { + this.url = url; + } + + // Helper methods + // Extract text from the text property + private String extractTextFromProperty(JsonNode properties, String fieldName, String textType) { + if (properties.has(fieldName)) { + JsonNode fieldNode = properties.get(fieldName).get(textType); + if (fieldNode.isArray() && fieldNode.size() > 0) { + return fieldNode.get(0).get("text").get("content").asText(); + } + } + return null; + } + + // Extract participants from the comma-separated text property + private List extractParticipants(JsonNode properties) { + String participantsString = extractTextFromProperty(properties, "참여 학생", "rich_text"); + if (participantsString != null && !participantsString.isEmpty()) { + return Arrays.asList(participantsString.split("\\s*,\\s*")); + } + return new ArrayList<>(); + } + + // Extract multi-select property + private List extractMultiSelect(JsonNode properties, String fieldName) { + List result = new ArrayList<>(); + if (properties.has(fieldName)) { + JsonNode multiSelectNode = properties.get(fieldName).get("multi_select"); + if (multiSelectNode.isArray()) { + multiSelectNode.forEach(node -> result.add(node.get("name").asText())); + } + } + return result; + } + + // Extract multi-select property as Integer + private List extractIntegerMultiSelect(JsonNode properties, String fieldName) { + List result = new ArrayList<>(); + if (properties.has(fieldName)) { + JsonNode multiSelectNode = properties.get(fieldName).get("multi_select"); + if (multiSelectNode.isArray()) { + multiSelectNode.forEach(node -> result.add(Integer.parseInt(node.get("name").asText()))); + } + } + return result; + } +} diff --git a/src/main/java/com/scg/stop/aihub/service/AiHubService.java b/src/main/java/com/scg/stop/aihub/service/AiHubService.java new file mode 100644 index 00000000..b4178255 --- /dev/null +++ b/src/main/java/com/scg/stop/aihub/service/AiHubService.java @@ -0,0 +1,200 @@ +package com.scg.stop.aihub.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.scg.stop.aihub.dto.request.AiHubDatasetRequest; +import com.scg.stop.aihub.dto.request.AiHubModelRequest; +import com.scg.stop.aihub.dto.response.AiHubDatasetResponse; +import com.scg.stop.aihub.dto.response.AiHubModelResponse; +import com.scg.stop.global.exception.BadRequestException; +import com.scg.stop.global.exception.ExceptionCode; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestTemplate; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Transactional +public class AiHubService { + + // Notion API values + @Value("${spring.notion.secretKey}") + private String secretKey; + + @Value("${spring.notion.version}") + private String version; + + @Value("${spring.notion.databaseId.model}") + private String modelDatabaseId; + + @Value("${spring.notion.databaseId.dataset}") + private String datasetDatabaseId; + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + + // Constants for Notion DB property names + private static final String TITLE_PROPERTY = "제목"; + private static final String LEARNING_MODELS_PROPERTY = "학습 모델"; + private static final String DATA_TYPES_PROPERTY = "데이터 유형"; + private static final String TOPICS_PROPERTY = "주제 분류"; + private static final String DEVELOPMENT_YEARS_PROPERTY = "개발 년도"; + private static final String CONSTRUCTION_YEARS_PROPERTY = "구축 년도"; + private static final String PROFESSOR_PROPERTY = "담당 교수"; + private static final String PARTICIPANTS_PROPERTY = "참여 학생"; + + /** + * Fetches AI Hub models from Notion database + * + * @param aiHubModelRequest Request object containing search criteria + * @param pageable Pageable object containing page number and size + * @return Page of AI Hub models + */ + public Page getAiHubModels(AiHubModelRequest aiHubModelRequest, Pageable pageable) { + return fetchNotionData(modelDatabaseId, aiHubModelRequest, pageable, AiHubModelResponse.class); + } + + /** + * Fetches AI Hub datasets from Notion database + * + * @param aiHubDatasetRequest Request object containing search criteria + * @param pageable Pageable object containing page number and size + * @return Page of AI Hub datasets + */ + public Page getAiHubDatasets(AiHubDatasetRequest aiHubDatasetRequest, Pageable pageable) { + return fetchNotionData(datasetDatabaseId, aiHubDatasetRequest, pageable, AiHubDatasetResponse.class); + } + + + /** + * Fetches data from Notion database + * + * @param databaseId Notion database ID + * @param requestObject Request object containing search criteria + * @param pageable Pageable object containing page number and size + * @param responseType Class type of response object + * @param Type of response object + * @return Page of response objects + */ + private Page fetchNotionData(String databaseId, Object requestObject, Pageable pageable, Class responseType) { + try { + String url = "https://api.notion.com/v1/databases/" + databaseId + "/query"; + + HttpHeaders headers = createHeaders(); + Map requestBody = createRequestBody(requestObject); + HttpEntity> requestEntity = new HttpEntity<>(requestBody, headers); + + ResponseEntity response = restTemplate.exchange(url, HttpMethod.POST, requestEntity, String.class); + JsonNode root = objectMapper.readTree(response.getBody()).get("results"); + List entities = objectMapper.convertValue(root, objectMapper.getTypeFactory().constructCollectionType(List.class, responseType)); + + int start = Math.min((int) pageable.getOffset(), entities.size()); + int end = Math.min(start + pageable.getPageSize(), entities.size()); + return new PageImpl<>(entities.subList(start, end), pageable, entities.size()); + + } catch (Exception e) { + throw new BadRequestException(ExceptionCode.FAILED_TO_FETCH_NOTION_DATA); + } + } + + // Helper methods for creating request header + private HttpHeaders createHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(secretKey); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Notion-Version", version); + return headers; + } + + // Helper method for creating request body + private Map createRequestBody(Object requestObject) { + Map requestBody = new HashMap<>(); + Map filter = new HashMap<>(); + List> andConditions = new ArrayList<>(); + + // Case for AiHubModelRequest + if (requestObject instanceof AiHubModelRequest) { + AiHubModelRequest aiHubModelRequest = (AiHubModelRequest) requestObject; + + // Convert Integer year to String for query + List developmentYears = new ArrayList<>(); + if (aiHubModelRequest.getDevelopmentYears() != null) { + aiHubModelRequest.getDevelopmentYears().forEach(year -> developmentYears.add(year.toString())); + } + + addStringFilterCondition(aiHubModelRequest.getTitle(), TITLE_PROPERTY, "title", andConditions); + addMultiSelectFilterConditions(aiHubModelRequest.getLearningModels(), LEARNING_MODELS_PROPERTY, andConditions); + addMultiSelectFilterConditions(aiHubModelRequest.getTopics(), TOPICS_PROPERTY, andConditions); + addMultiSelectFilterConditions(developmentYears, DEVELOPMENT_YEARS_PROPERTY, andConditions); + addStringFilterCondition(aiHubModelRequest.getProfessor(), PROFESSOR_PROPERTY, "rich_text", andConditions); + addMultipleStringFilterConditions(aiHubModelRequest.getParticipants(), PARTICIPANTS_PROPERTY, "rich_text", andConditions); + + } + + // Case for AiHubDatasetRequest + else if (requestObject instanceof AiHubDatasetRequest) { + AiHubDatasetRequest aiHubDatasetRequest = (AiHubDatasetRequest) requestObject; + + // Convert Integer year to String for query + List constructionYears = new ArrayList<>(); + if (aiHubDatasetRequest.getDevelopmentYears() != null) { + aiHubDatasetRequest.getDevelopmentYears().forEach(year -> constructionYears.add(year.toString())); + } + + addStringFilterCondition(aiHubDatasetRequest.getTitle(), TITLE_PROPERTY, "title", andConditions); + addMultiSelectFilterConditions(aiHubDatasetRequest.getDataTypes(), DATA_TYPES_PROPERTY, andConditions); + addMultiSelectFilterConditions(aiHubDatasetRequest.getTopics(), TOPICS_PROPERTY, andConditions); + addMultiSelectFilterConditions(constructionYears, CONSTRUCTION_YEARS_PROPERTY, andConditions); + addStringFilterCondition(aiHubDatasetRequest.getProfessor(), PROFESSOR_PROPERTY, "rich_text", andConditions); + addMultipleStringFilterConditions(aiHubDatasetRequest.getParticipants(), PARTICIPANTS_PROPERTY, "rich_text", andConditions); + } + + // AND conditions to filter + if (!andConditions.isEmpty()) { + filter.put("and", andConditions); + requestBody.put("filter", filter); + } + + return requestBody; + } + + // Helper method for adding String filter condition + private void addStringFilterCondition(String value, String property, String filterType, List> conditions) { + if (value != null && !value.isEmpty()) { + Map condition = new HashMap<>(); + condition.put("property", property); + condition.put(filterType, Map.of("contains", value)); + conditions.add(condition); + } + } + + // Helper method for adding multiple String (comma-separated in db) filter conditions + private void addMultipleStringFilterConditions(List values, String property, String filterType, List> conditions) { + if (values != null && !values.isEmpty()) { + values.forEach(value -> addStringFilterCondition(value, property, filterType, conditions)); + } + } + + // Helper method for adding MultiSelect filter conditions + private void addMultiSelectFilterConditions(List criteria, String property, List> conditions) { + if (criteria != null && !criteria.isEmpty()) { + criteria.forEach(criterion -> { + Map filterCondition = new HashMap<>(); + filterCondition.put("property", property); + filterCondition.put("multi_select", Map.of("contains", criterion)); + conditions.add(filterCondition); + }); + } + } +} diff --git a/src/main/java/com/scg/stop/auth/JwtUtil.java b/src/main/java/com/scg/stop/auth/JwtUtil.java new file mode 100644 index 00000000..4fcc4970 --- /dev/null +++ b/src/main/java/com/scg/stop/auth/JwtUtil.java @@ -0,0 +1,101 @@ +package com.scg.stop.auth; + +import com.scg.stop.auth.domain.UserToken; +import com.scg.stop.global.exception.ExceptionCode; +import com.scg.stop.global.exception.SocialLoginException; +import com.scg.stop.user.domain.User; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.JwtBuilder; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import javax.crypto.SecretKey; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class JwtUtil { + + private final SecretKey secretKey; + private final Long accessTokenExpiry; + private final Long refreshTokenExpiry; + + + public JwtUtil( + @Value("${spring.auth.jwt.secret-key}") final String secretKey, + @Value("${spring.auth.jwt.access-token-expiry}") final Long accessTokenExpiry, + @Value("${spring.auth.jwt.refresh-token-expiry}") final Long refreshTokenExpiry + ) { + this.secretKey = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); + this.accessTokenExpiry = accessTokenExpiry; + this.refreshTokenExpiry = refreshTokenExpiry; + } + + // 토큰 생성 // + public UserToken createLoginToken(String subject, User user) { + String refreshToken = createToken("",refreshTokenExpiry, null); + String accessToken = createToken(subject, accessTokenExpiry, user); + return new UserToken(accessToken, refreshToken); + } + public String createToken(String subject, Long expiredMs, User user) { + final Date now = new Date(); + final Date expiredDate = new Date(now.getTime() + expiredMs); + JwtBuilder jwtBuilder = Jwts.builder() + .setSubject(subject) + .setIssuedAt(now) + .setExpiration(expiredDate) + .signWith(secretKey); + if (user != null) { + jwtBuilder.claim("userType", user.getUserType()); + jwtBuilder.claim("userId", user.getId()); + } + return jwtBuilder.compact(); + } + + public String reissueAccessToken(String subject, User user) { + return createToken(subject, accessTokenExpiry, user); + } + + // 토근 정보 추출 // + public Jws parseToken(String token) { + return Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token); + } + public Claims getSubject(String token) { + return parseToken(token) + .getBody(); + } + + // 유효성 검사 // + public void validateRefreshToken(String refreshToken) { + try { + parseToken(refreshToken); + } catch (JwtException e) { + throw new SocialLoginException(ExceptionCode.INVALID_REFRESH_TOKEN); + } + } + + public boolean isAccessTokenValid(String accessToken) { + try { + parseToken(accessToken); + }catch (JwtException e) { + return false; + } + return true; + } + + public boolean isAccessTokenExpired(String accessToken) { + try { + parseToken(accessToken); + } catch (ExpiredJwtException e){ + return true; + } + return false; + } +} diff --git a/src/main/java/com/scg/stop/auth/annotation/AuthUser.java b/src/main/java/com/scg/stop/auth/annotation/AuthUser.java new file mode 100644 index 00000000..b77674c6 --- /dev/null +++ b/src/main/java/com/scg/stop/auth/annotation/AuthUser.java @@ -0,0 +1,15 @@ +package com.scg.stop.auth.annotation; + +import com.scg.stop.user.domain.AccessType; +import com.scg.stop.user.domain.User; +import com.scg.stop.user.domain.UserType; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface AuthUser { + AccessType[] accessType(); +} diff --git a/src/main/java/com/scg/stop/auth/config/AuthUserArgumentResolver.java b/src/main/java/com/scg/stop/auth/config/AuthUserArgumentResolver.java new file mode 100644 index 00000000..3ad2a0bf --- /dev/null +++ b/src/main/java/com/scg/stop/auth/config/AuthUserArgumentResolver.java @@ -0,0 +1,137 @@ +package com.scg.stop.auth.config; + +import com.scg.stop.auth.annotation.AuthUser; +import com.scg.stop.auth.JwtUtil; +import com.scg.stop.global.exception.BadRequestException; +import com.scg.stop.global.exception.ExceptionCode; +import com.scg.stop.global.exception.InvalidJwtException; +import com.scg.stop.global.exception.UnauthorizedException; +import com.scg.stop.user.domain.AccessType; +import com.scg.stop.user.domain.User; +import com.scg.stop.user.domain.UserType; +import com.scg.stop.user.repository.UserRepository; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpHeaders; +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 +@RequiredArgsConstructor +@Slf4j +public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver { + + private final UserRepository userRepository; + private final JwtUtil jwtUtil; + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + + HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); + + if (request == null) { + throw new InvalidJwtException(ExceptionCode.FAILED_TO_VALIDATE_TOKEN); + } + + AccessType[] allowedTypes = Objects.requireNonNull(parameter.getParameterAnnotation(AuthUser.class)).accessType(); + List accessTypeList = Arrays.asList(allowedTypes); + + if(accessTypeList.contains(AccessType.OPTIONAL) && !hasAccessToken(request)) + { + return null; + } + + String contextPath = request.getRequestURI(); + String accessToken = extractAccessToken(request); + String refreshToken = extractRefreshToken(request); + + //검증 + if (jwtUtil.isAccessTokenValid(accessToken)) { + User extractedUser = extractUser(accessToken); + UserType extractedUserType = extractUser(accessToken).getUserType(); + + // TEMP USER 이고 REGISTER PATH 로 요청이면 REGISTER 페이지로 안내 + // 그 외 TEMP USER 는 전부 예외 처리 + String registerPath = "/auth/register"; + if (extractedUserType.equals(UserType.TEMP) ) { + if (contextPath.equals(registerPath)) { + return extractedUser; + } + else { + throw new BadRequestException(ExceptionCode.REGISTER_NOT_FINISHED); + } + } + + + if (accessTypeList.contains(AccessType.ALL) || accessTypeList.contains(AccessType.OPTIONAL)) { + return extractedUser; + } + else if (extractedUserType.equals(UserType.ADMIN)) { + if (accessTypeList.contains(AccessType.ADMIN)) return extractedUser; + } + else if (extractedUserType.equals(UserType.COMPANY) || extractedUserType.equals(UserType.INACTIVE_COMPANY)){ + if (accessTypeList.contains(AccessType.COMPANY)) return extractedUser; + } + else if (extractedUserType.equals(UserType.PROFESSOR) || extractedUserType.equals(UserType.INACTIVE_PROFESSOR)) { + if (accessTypeList.contains(AccessType.PROFESSOR)) return extractedUser; + } + else if (extractedUserType.equals(UserType.STUDENT) || extractedUserType.equals(UserType.OTHERS)) { + if (accessTypeList.contains(AccessType.STUDENT)) return extractedUser; + } + } + + throw new UnauthorizedException(ExceptionCode.NOT_AUTHORIZED); + } + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthUser.class); + } + + private String extractAccessToken(HttpServletRequest request) { + final String BEARER = "Bearer "; + String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + if (authHeader == null || !authHeader.startsWith(BEARER)) { + throw new InvalidJwtException(ExceptionCode.INVALID_ACCESS_TOKEN); + } + return authHeader.substring(BEARER.length()).trim(); + } + + private String extractRefreshToken(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + + if (cookies == null) { + throw new InvalidJwtException(ExceptionCode.INVALID_REFRESH_TOKEN); + } + + return Arrays.stream(cookies) + .filter(cookie -> cookie.getName().equals("refresh-token")) + .findFirst() + .orElseThrow(() -> new InvalidJwtException(ExceptionCode.INVALID_REFRESH_TOKEN)) + .getValue(); + } + + private User extractUser(String accessToken) { + Long userId = Long.valueOf(jwtUtil.getSubject(accessToken).get("userId", Integer.class)); + + return userRepository.findById(userId) + .orElseThrow(() -> new BadRequestException(ExceptionCode.NOT_FOUND_USER_ID)); + } + + private boolean hasAccessToken(HttpServletRequest request) { + final String BEARER = "Bearer "; + final String BEARER_NULL = "Bearer null"; + String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + return authHeader != null && authHeader.startsWith(BEARER) && !authHeader.equalsIgnoreCase(BEARER_NULL); + } +} diff --git a/src/main/java/com/scg/stop/auth/controller/AuthController.java b/src/main/java/com/scg/stop/auth/controller/AuthController.java new file mode 100644 index 00000000..14a73da7 --- /dev/null +++ b/src/main/java/com/scg/stop/auth/controller/AuthController.java @@ -0,0 +1,99 @@ +package com.scg.stop.auth.controller; + +import com.scg.stop.auth.annotation.AuthUser; +import com.scg.stop.auth.domain.UserToken; +import com.scg.stop.auth.domain.request.RegisterRequest; +import com.scg.stop.auth.domain.response.AccessTokenResponse; +import com.scg.stop.auth.domain.response.RegisterResponse; +import com.scg.stop.auth.service.AuthService; +import com.scg.stop.user.domain.AccessType; +import com.scg.stop.user.domain.User; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/auth") +@Slf4j +public class AuthController { + private static final int ONE_WEEK_SECONDS = 604800; + + @Value("${spring.redirectUri}") + private String REDIRECT_URI; + + private final AuthService authService; + + @GetMapping(value = "/login/kakao") + public ResponseEntity kakaoLogin ( + @RequestParam("code") String accessCode , + HttpServletResponse response + ) throws IOException { + UserToken userTokens = authService.login(accessCode); + + ResponseCookie cookie = ResponseCookie.from("refresh-token", userTokens.getRefreshToken()) + .maxAge(ONE_WEEK_SECONDS) + .secure(true) + .httpOnly(true) + .sameSite("None") +// .domain(".localhost") // TODO: domain 수정 + .path("/") + .build(); + + ResponseCookie AccessTokenCookie = ResponseCookie.from("access-token", userTokens.getAccessToken()) + .maxAge(ONE_WEEK_SECONDS) + .secure(true) + .httpOnly(false) + .sameSite("None") +// .domain(".localhost") // TODO: domain 수정 + .path("/") + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + response.addHeader(HttpHeaders.SET_COOKIE, AccessTokenCookie.toString()); + response.sendRedirect(REDIRECT_URI); + return ResponseEntity.status(HttpStatus.FOUND).build(); + } + + @PostMapping("/register") + public ResponseEntity register( + @RequestBody @Valid RegisterRequest registerRequest, + @AuthUser(accessType = {AccessType.ALL}) User user) { + return ResponseEntity.status(HttpStatus.CREATED).body(authService.finishRegister(user, registerRequest)); + + } + + @PostMapping("/reissue") + public ResponseEntity reissueToken( + @CookieValue("refresh-token") String refreshToken, + @RequestHeader("Authorization") String authHeader + ) { + String reissuedToken = authService.reissueAccessToken(refreshToken, authHeader); + return ResponseEntity.ok(new AccessTokenResponse(reissuedToken)); + } + + @PostMapping(value = "/logout") + public ResponseEntity logout( + @CookieValue("refresh-token") String refreshToken + ) { + + authService.logout(refreshToken); + return ResponseEntity.noContent().build(); + } + +} + diff --git a/src/main/java/com/scg/stop/auth/domain/RefreshToken.java b/src/main/java/com/scg/stop/auth/domain/RefreshToken.java new file mode 100644 index 00000000..c397aa54 --- /dev/null +++ b/src/main/java/com/scg/stop/auth/domain/RefreshToken.java @@ -0,0 +1,27 @@ +package com.scg.stop.auth.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + + +// TODO : Redis Migration +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RefreshToken { + + @Id + private String token; + + @Column(nullable = false) + private Long userId; + + public RefreshToken(final String token, final Long userId) { + this.token = token; + this.userId = userId; + } +} diff --git a/src/main/java/com/scg/stop/auth/domain/StudentInfoDto.java b/src/main/java/com/scg/stop/auth/domain/StudentInfoDto.java new file mode 100644 index 00000000..3a828b2f --- /dev/null +++ b/src/main/java/com/scg/stop/auth/domain/StudentInfoDto.java @@ -0,0 +1,17 @@ +package com.scg.stop.auth.domain; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +public class StudentInfoDto { + + @NotBlank + private String department; + + @NotBlank + private String studentNumber; +} diff --git a/src/main/java/com/scg/stop/auth/domain/UserToken.java b/src/main/java/com/scg/stop/auth/domain/UserToken.java new file mode 100644 index 00000000..f4ae1c85 --- /dev/null +++ b/src/main/java/com/scg/stop/auth/domain/UserToken.java @@ -0,0 +1,11 @@ +package com.scg.stop.auth.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class UserToken { + private final String accessToken; + private final String refreshToken; +} diff --git a/src/main/java/com/scg/stop/auth/domain/request/RegisterRequest.java b/src/main/java/com/scg/stop/auth/domain/request/RegisterRequest.java new file mode 100644 index 00000000..5ffb4d06 --- /dev/null +++ b/src/main/java/com/scg/stop/auth/domain/request/RegisterRequest.java @@ -0,0 +1,69 @@ +package com.scg.stop.auth.domain.request; + +import com.scg.stop.auth.domain.StudentInfoDto; +import com.scg.stop.global.exception.BadRequestException; +import com.scg.stop.global.exception.ExceptionCode; +import com.scg.stop.user.domain.Student; +import com.scg.stop.user.domain.User; +import com.scg.stop.user.domain.UserType; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.util.Arrays; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class RegisterRequest { + + @NotBlank(message = "이름을 입력해주세요.") + private String name; + + @NotBlank(message = "전화번호를 입력해주세요.") + private String phoneNumber; + + @NotNull(message = "회원 유형을 입력해주세요.") + private UserType userType; + + @NotBlank(message = "이메일을 입력해주세요.") + private String email; + + private String signUpSource; + + private StudentInfoDto studentInfo; + + private String division; + + private String position; + + public RegisterRequest(String name, String phoneNumber, UserType userType, String email, String signUpSource, + StudentInfoDto studentInfo, String division, String position) { + validateUserType(userType); + validateStudentInfo(userType, studentInfo); + this.name = name; + this.phoneNumber = phoneNumber; + this.userType = userType; + this.email = email; + this.signUpSource = signUpSource; + this.studentInfo = studentInfo; + this.division = division; + this.position = position; + } + + private void validateStudentInfo(UserType userType, StudentInfoDto studentInfo) { + if(userType.equals(UserType.STUDENT)){ + if(studentInfo.getStudentNumber() == null || studentInfo.getDepartment() == null){ + throw new BadRequestException(ExceptionCode.INVALID_STUDENTINFO); + } + } + } + + private void validateUserType(UserType userType) { + if(Arrays.asList(UserType.ADMIN, UserType.PROFESSOR, UserType.COMPANY).contains(userType)) { + throw new BadRequestException(ExceptionCode.INVALID_USERTYPE); + } + } +} diff --git a/src/main/java/com/scg/stop/auth/domain/response/AccessTokenResponse.java b/src/main/java/com/scg/stop/auth/domain/response/AccessTokenResponse.java new file mode 100644 index 00000000..7c4d46c3 --- /dev/null +++ b/src/main/java/com/scg/stop/auth/domain/response/AccessTokenResponse.java @@ -0,0 +1,12 @@ +package com.scg.stop.auth.domain.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class AccessTokenResponse { + private String accessToken; +} \ No newline at end of file diff --git a/src/main/java/com/scg/stop/auth/domain/response/RegisterResponse.java b/src/main/java/com/scg/stop/auth/domain/response/RegisterResponse.java new file mode 100644 index 00000000..59da94ab --- /dev/null +++ b/src/main/java/com/scg/stop/auth/domain/response/RegisterResponse.java @@ -0,0 +1,28 @@ +package com.scg.stop.auth.domain.response; + +import com.scg.stop.user.domain.User; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor + +public class RegisterResponse { + private String name; + private String email; + private String phone; + + private RegisterResponse(String name, String email, String phone) { + this.name = name; + this.email = email; + this.phone = phone; + } + public static RegisterResponse from(User user) { + return new RegisterResponse( + user.getName(), + user.getEmail(), + user.getPhone() + ); + } +} diff --git a/src/main/java/com/scg/stop/auth/infrastructure/KakaoOAuthProvider.java b/src/main/java/com/scg/stop/auth/infrastructure/KakaoOAuthProvider.java new file mode 100644 index 00000000..16e20e71 --- /dev/null +++ b/src/main/java/com/scg/stop/auth/infrastructure/KakaoOAuthProvider.java @@ -0,0 +1,108 @@ +package com.scg.stop.auth.infrastructure; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.scg.stop.global.exception.ExceptionCode; +import com.scg.stop.global.exception.SocialLoginException; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +@Component +@Slf4j +public class KakaoOAuthProvider { + private final RestTemplate restTemplate; + private final String clientId; +// private final String clientSecret; + private final String redirectUri; + private final String tokenUri; + private final String userInfoUri; + + public KakaoOAuthProvider( + RestTemplate restTemplate, + @Value("${spring.security.oauth2.client.registration.kakao.client-id}") String clientId, +// @Value("${spring.security.oauth2.client.registration.kakao.client-secret}") String clientSecret, + @Value("${spring.security.oauth2.client.registration.kakao.redirect-uri}") String redirectUri, + @Value("${spring.security.oauth2.client.provider.kakao.token-uri}") String tokenUri, + @Value("${spring.security.oauth2.client.provider.kakao.user-info-uri}") String userInfoUri) { + this.restTemplate = restTemplate; + this.clientId = clientId; +// this.clientSecret = clientSecret; + this.redirectUri = redirectUri; + this.tokenUri = tokenUri; + this.userInfoUri = userInfoUri; + } + public KakaoUserInfo getUserInfo(String kakaoAccessToken) { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(kakaoAccessToken); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + Map params = new HashMap<>(); + params.put("secure_resource", true); + + HttpEntity> requestEntity = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange( + userInfoUri, + HttpMethod.GET, + requestEntity, + KakaoUserInfo.class, + params + ); + + if (response.getStatusCode().is2xxSuccessful()) { + return response.getBody(); + } + + throw new SocialLoginException(ExceptionCode.UNABLE_TO_GET_USER_INFO); + } + + public String fetchKakaoAccessToken(String code) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("code", code); + params.add("client_id", clientId); +// params.add("client_secret", clientSecret); + params.add("redirect_uri", redirectUri); + params.add("grant_type", "authorization_code"); + HttpEntity> requestEntity = new HttpEntity<>(params, headers); + + ResponseEntity response = restTemplate.exchange( + tokenUri, + HttpMethod.POST, + requestEntity, + KakaoTokenResponse.class + ); + + return Optional.ofNullable(response.getBody()) + .orElseThrow(() -> new SocialLoginException(ExceptionCode.UNABLE_TO_GET_ACCESS_TOKEN)) + .getAccessToken(); + } + + + + @Getter + public static class KakaoTokenResponse { + @JsonProperty("access_token") + private String accessToken; + + @JsonProperty("refresh_token") + private String refreshToken; + + @JsonProperty("expires_in") + private Integer expiresIn; + } +} diff --git a/src/main/java/com/scg/stop/auth/infrastructure/KakaoUserInfo.java b/src/main/java/com/scg/stop/auth/infrastructure/KakaoUserInfo.java new file mode 100644 index 00000000..a3dbc1cb --- /dev/null +++ b/src/main/java/com/scg/stop/auth/infrastructure/KakaoUserInfo.java @@ -0,0 +1,22 @@ +package com.scg.stop.auth.infrastructure; + + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; + +public class KakaoUserInfo { + + @Getter + @JsonProperty("id") + private String socialLoginId; + + @JsonProperty("kakao_account") + private KakaoAccount kakaoAccount; + + private static class KakaoAccount { + @JsonProperty("profile") + private KakaoProfile kakaoProfile; + } + + private static class KakaoProfile {} +} diff --git a/src/main/java/com/scg/stop/auth/repository/RefreshTokenRepository.java b/src/main/java/com/scg/stop/auth/repository/RefreshTokenRepository.java new file mode 100644 index 00000000..472c299a --- /dev/null +++ b/src/main/java/com/scg/stop/auth/repository/RefreshTokenRepository.java @@ -0,0 +1,7 @@ +package com.scg.stop.auth.repository; + +import com.scg.stop.auth.domain.RefreshToken; +import org.springframework.data.repository.CrudRepository; + +public interface RefreshTokenRepository extends CrudRepository { +} diff --git a/src/main/java/com/scg/stop/auth/service/AuthService.java b/src/main/java/com/scg/stop/auth/service/AuthService.java new file mode 100644 index 00000000..a128f92d --- /dev/null +++ b/src/main/java/com/scg/stop/auth/service/AuthService.java @@ -0,0 +1,115 @@ +package com.scg.stop.auth.service; + +import com.scg.stop.auth.JwtUtil; +import com.scg.stop.auth.domain.RefreshToken; +import com.scg.stop.auth.domain.UserToken; +import com.scg.stop.auth.domain.request.RegisterRequest; +import com.scg.stop.auth.domain.response.RegisterResponse; +import com.scg.stop.auth.infrastructure.KakaoOAuthProvider; +import com.scg.stop.auth.infrastructure.KakaoUserInfo; +import com.scg.stop.auth.repository.RefreshTokenRepository; +import com.scg.stop.global.exception.BadRequestException; +import com.scg.stop.global.exception.ExceptionCode; +import com.scg.stop.global.exception.InvalidJwtException; +import com.scg.stop.user.domain.Application; +import com.scg.stop.user.domain.Department; +import com.scg.stop.user.domain.Student; +import com.scg.stop.user.domain.User; +import com.scg.stop.user.domain.UserType; +import com.scg.stop.user.repository.ApplicationRepository; +import com.scg.stop.user.repository.DepartmentRepository; +import com.scg.stop.user.repository.StudentRepository; +import com.scg.stop.user.repository.UserRepository; +import java.util.Arrays; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class AuthService { + private final RefreshTokenRepository refreshTokenRepository; + private final StudentRepository studentRepository; + private final DepartmentRepository departmentRepository; + private final UserRepository userRepository; + private final ApplicationRepository applicationRepository; + private final JwtUtil jwtUtil; + private final KakaoOAuthProvider kakaoOAuthProvider; + + public UserToken login(String accessCode) { + String kakaoAccessToken = kakaoOAuthProvider.fetchKakaoAccessToken(accessCode); + KakaoUserInfo userInfo = kakaoOAuthProvider.getUserInfo(kakaoAccessToken); + + User user = findOrCreateUser(userInfo.getSocialLoginId()); + + UserToken userToken = jwtUtil.createLoginToken(user.getId().toString(), user); + RefreshToken refreshToken = new RefreshToken(userToken.getRefreshToken(), user.getId()); + refreshTokenRepository.save(refreshToken); + return userToken; + } + + // 카카오 & 네이버 중 하나만 회원가입 가능 + private User findOrCreateUser(String socialLoginId) { + return userRepository.findBySocialLoginId(socialLoginId) + .orElseGet(() -> createUser(socialLoginId)); + } + + public RegisterResponse finishRegister(User user, RegisterRequest registerRequest) { + if (registerRequest.getUserType().equals(UserType.STUDENT)) { + Department department = departmentRepository.findByName( + registerRequest.getStudentInfo().getDepartment()) + .orElseThrow(() -> new BadRequestException(ExceptionCode.NOT_FOUND_DEPARTMENT)); + + Student student = Student.of(registerRequest.getStudentInfo().getStudentNumber(), + user + , department); + studentRepository.save(student); + user.updateStudentInfo(student); + } + else if (Arrays.asList(UserType.INACTIVE_PROFESSOR, UserType.INACTIVE_COMPANY) + .contains(registerRequest.getUserType())) { + Application application = new Application(registerRequest.getDivision(), registerRequest.getPosition(), + user); + applicationRepository.save(application); + user.updateApplication(application); + } + user.register(registerRequest.getName(), + registerRequest.getEmail(), + registerRequest.getPhoneNumber(), + registerRequest.getUserType(), + registerRequest.getSignUpSource()); + userRepository.save(user); + return RegisterResponse.from(user); + } + private User createUser(String socialLoginId) { + return userRepository.save(new User(socialLoginId)); + } + + public void logout(String refreshToken) { + refreshTokenRepository.deleteById(refreshToken); + } + + public String reissueAccessToken(String refreshToken, String authHeader) { + //Bearer 제거 + String accessToken = authHeader.split(" ")[1]; + + //토큰 만료, 비밀키 무결성 검사 + jwtUtil.validateRefreshToken(refreshToken); + + //Access Token 이 유효한 경우 -> 재반환 + if (jwtUtil.isAccessTokenValid(accessToken)) { + return accessToken; + } + + //Access Token 이 만료된 경우 -> Refresh Token DB 검증 후 재발급 + if (jwtUtil.isAccessTokenExpired(accessToken)) { + RefreshToken foundRefreshToken = refreshTokenRepository.findById(refreshToken) + .orElseThrow(() -> new InvalidJwtException(ExceptionCode.INVALID_REFRESH_TOKEN)); + User user = userRepository.findById(foundRefreshToken.getUserId()) + .orElseThrow(() -> new BadRequestException(ExceptionCode.NOT_FOUND_USER_ID)); + return jwtUtil.reissueAccessToken(foundRefreshToken.getUserId().toString(), user); + } + throw new InvalidJwtException(ExceptionCode.FAILED_TO_VALIDATE_TOKEN); + } +} diff --git a/src/main/java/com/scg/stop/event/controller/EventNoticeController.java b/src/main/java/com/scg/stop/event/controller/EventNoticeController.java new file mode 100644 index 00000000..2a814aae --- /dev/null +++ b/src/main/java/com/scg/stop/event/controller/EventNoticeController.java @@ -0,0 +1,77 @@ +package com.scg.stop.event.controller; + + +import com.scg.stop.auth.annotation.AuthUser; +import com.scg.stop.event.dto.request.EventNoticeRequest; +import com.scg.stop.event.dto.response.EventNoticeListElementResponse; +import com.scg.stop.event.dto.response.EventNoticeResponse; +import com.scg.stop.event.service.EventNoticeService; +import com.scg.stop.user.domain.AccessType; +import com.scg.stop.user.domain.User; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RequestMapping("/eventNotices") +@RestController +public class EventNoticeController { + + private final EventNoticeService eventNoticeService; + + // Create a new eventNotice + @PostMapping + public ResponseEntity createEventNotice( + @RequestBody @Valid EventNoticeRequest createEventNoticeDto, + @AuthUser(accessType = {AccessType.ADMIN}) User user) { + return ResponseEntity.status(HttpStatus.CREATED).body(eventNoticeService.createEventNotice(createEventNoticeDto)); + } + + // Get a list of eventNotices + @GetMapping + public ResponseEntity> getEventNoticeList( + @RequestParam(value = "terms", required = false) String searchTerm, + @RequestParam(value = "scope", defaultValue = "both") String searchScope, + @PageableDefault(page = 0, size = 10, sort = "createdAt", direction = Sort.Direction.ASC) Pageable pageable) { + + // Enforce that searchScope is ignored if searchTerm is null + if (searchTerm == null || searchTerm.isEmpty()) { + searchScope = null; // Ignore search scope + } + + Page eventNoticeList = eventNoticeService.getEventNoticeList(searchTerm, searchScope, pageable); + return ResponseEntity.status(HttpStatus.OK).body(eventNoticeList); + } + + // Get a corresponding eventNotice + @GetMapping("/{eventNoticeId}") + public ResponseEntity getEventNotice( + @PathVariable("eventNoticeId") Long eventNoticeId) { + EventNoticeResponse eventNotice = eventNoticeService.getEventNotice(eventNoticeId); + return ResponseEntity.status(HttpStatus.OK).body(eventNotice); + } + + // Update a corresponding eventNotice + @PutMapping("/{eventNoticeId}") + public ResponseEntity updateEventNotice( + @PathVariable("eventNoticeId") Long eventNoticeId, + @RequestBody @Valid EventNoticeRequest updateEventNoticeDto, + @AuthUser(accessType = {AccessType.ADMIN}) User user) { + return ResponseEntity.status(HttpStatus.OK).body(eventNoticeService.updateEventNotice(eventNoticeId, updateEventNoticeDto)); + } + + // Delete a corresponding eventNotice + @DeleteMapping("/{eventNoticeId}") + public ResponseEntity deleteEventNotice( + @PathVariable("eventNoticeId") Long eventNoticeId, + @AuthUser(accessType = {AccessType.ADMIN}) User user) { + eventNoticeService.deleteEventNotice(eventNoticeId); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/scg/stop/event/controller/EventPeriodController.java b/src/main/java/com/scg/stop/event/controller/EventPeriodController.java new file mode 100644 index 00000000..d9b55946 --- /dev/null +++ b/src/main/java/com/scg/stop/event/controller/EventPeriodController.java @@ -0,0 +1,56 @@ +package com.scg.stop.event.controller; + +import static com.scg.stop.user.domain.AccessType.ADMIN; + +import com.scg.stop.auth.annotation.AuthUser; +import com.scg.stop.event.dto.request.EventPeriodRequest; +import com.scg.stop.event.dto.response.EventPeriodResponse; +import com.scg.stop.event.service.EventPeriodService; +import com.scg.stop.user.domain.User; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class EventPeriodController { + + private final EventPeriodService eventPeriodService; + + @PostMapping("/eventPeriods") + public ResponseEntity createEventPeriod( + @RequestBody @Valid EventPeriodRequest eventPeriodRequest, + @AuthUser(accessType = ADMIN) User user + ) { + EventPeriodResponse eventPeriodResponse = eventPeriodService.createEventPeriod(eventPeriodRequest); + return ResponseEntity.status(HttpStatus.CREATED).body(eventPeriodResponse); + } + + @GetMapping("/eventPeriod") + public ResponseEntity getEventPeriod(@AuthUser(accessType = ADMIN) User user) { + EventPeriodResponse eventPeriodResponse = eventPeriodService.getEventPeriod(); + return ResponseEntity.status(HttpStatus.OK).body(eventPeriodResponse); + } + + @GetMapping("/eventPeriods") + public ResponseEntity> getEventPeriods(@AuthUser(accessType = ADMIN) User user) { + List eventPeriodResponses = eventPeriodService.getEventPeriods(); + return ResponseEntity.status(HttpStatus.OK).body(eventPeriodResponses); + } + + @PutMapping("/eventPeriod") + public ResponseEntity updateEventPeriod( + @RequestBody @Valid EventPeriodRequest eventPeriodRequest, + @AuthUser(accessType = ADMIN) User user + ) { + EventPeriodResponse eventPeriodResponse = eventPeriodService.updateEventPeriod(eventPeriodRequest); + return ResponseEntity.status(HttpStatus.OK).body(eventPeriodResponse); + } +} diff --git a/src/main/java/com/scg/stop/event/domain/EventNotice.java b/src/main/java/com/scg/stop/event/domain/EventNotice.java new file mode 100644 index 00000000..24058b61 --- /dev/null +++ b/src/main/java/com/scg/stop/event/domain/EventNotice.java @@ -0,0 +1,74 @@ +package com.scg.stop.event.domain; + + +import com.scg.stop.file.domain.File; +import com.scg.stop.global.domain.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +import static jakarta.persistence.CascadeType.ALL; +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +@Entity +@Getter +@NoArgsConstructor(access = PROTECTED) +public class EventNotice extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @Column(nullable = false) + private String title; + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @Column(nullable = false) + private Integer hitCount = 0; + + @Column(nullable = false, columnDefinition = "TINYINT(1)") + private boolean fixed; + + @OneToMany(fetch = LAZY, mappedBy = "eventNotice", cascade = ALL, orphanRemoval = true) + private List files = new ArrayList<>(); + + private EventNotice(String title, String content, Integer hitCount, boolean fixed, List files) { + this.title = title; + this.content = content; + this.hitCount = hitCount; + this.fixed = fixed; + files.forEach(file -> file.setEventNotice(this)); + } + + public static EventNotice from(String title, String content, boolean fixed, List files) { + return new EventNotice( + title, + content, + 0, + fixed, + files + ); + } + + public void updateEventNotice(String title, String content, boolean fixed, List files) { + this.title = title; + this.content = content; + this.fixed = fixed; + this.files.clear(); + if (files != null) { + this.files.addAll(files); + files.forEach(file -> file.setEventNotice(this)); + } + } + + public void increaseHitCount() { + this.hitCount++; + } +} \ No newline at end of file diff --git a/src/main/java/com/scg/stop/event/domain/EventPeriod.java b/src/main/java/com/scg/stop/event/domain/EventPeriod.java new file mode 100644 index 00000000..5509511e --- /dev/null +++ b/src/main/java/com/scg/stop/event/domain/EventPeriod.java @@ -0,0 +1,43 @@ +package com.scg.stop.event.domain; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import com.scg.stop.global.domain.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = PROTECTED) +@AllArgsConstructor +public class EventPeriod extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private Integer year; + + @Column(nullable = false) + private LocalDateTime start; + + @Column(nullable = false) + private LocalDateTime end; + + public static EventPeriod of(Integer year, LocalDateTime start, LocalDateTime end) { + return new EventPeriod(null, year, start, end); + } + + public void update(LocalDateTime start, LocalDateTime end) { + this.start = start; + this.end = end; + } +} diff --git a/src/main/java/com/scg/stop/event/dto/request/EventNoticeRequest.java b/src/main/java/com/scg/stop/event/dto/request/EventNoticeRequest.java new file mode 100644 index 00000000..97392e02 --- /dev/null +++ b/src/main/java/com/scg/stop/event/dto/request/EventNoticeRequest.java @@ -0,0 +1,27 @@ +package com.scg.stop.event.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class EventNoticeRequest { + + @NotBlank(message = "제목을 입력해주세요.") + private String title; + + @NotBlank(message = "내용을 입력해주세요.") + private String content; + + @NotNull(message = "고정 여부를 입력해주세요.") + private boolean fixed; + + private List fileIds; + +} \ No newline at end of file diff --git a/src/main/java/com/scg/stop/event/dto/request/EventPeriodRequest.java b/src/main/java/com/scg/stop/event/dto/request/EventPeriodRequest.java new file mode 100644 index 00000000..789b80b4 --- /dev/null +++ b/src/main/java/com/scg/stop/event/dto/request/EventPeriodRequest.java @@ -0,0 +1,24 @@ +package com.scg.stop.event.dto.request; + +import static lombok.AccessLevel.PRIVATE; + +import jakarta.validation.constraints.NotNull; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; + +@Getter +@NoArgsConstructor(access = PRIVATE) +@AllArgsConstructor +public class EventPeriodRequest { + + @NotNull(message = "이벤트 시작 일시를 입력해주세요.") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + private LocalDateTime start; + + @NotNull(message = "이벤트 종료 일시를 입력해주세요.") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + private LocalDateTime end; +} diff --git a/src/main/java/com/scg/stop/event/dto/response/EventNoticeListElementResponse.java b/src/main/java/com/scg/stop/event/dto/response/EventNoticeListElementResponse.java new file mode 100644 index 00000000..f020c5a9 --- /dev/null +++ b/src/main/java/com/scg/stop/event/dto/response/EventNoticeListElementResponse.java @@ -0,0 +1,33 @@ +package com.scg.stop.event.dto.response; + + +import com.scg.stop.event.domain.EventNotice; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class EventNoticeListElementResponse { + private Long id; + private String title; + private Integer hitCount; + private boolean fixed; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + // Entity -> DTO + public static EventNoticeListElementResponse from(EventNotice eventNotice) { + return new EventNoticeListElementResponse( + eventNotice.getId(), + eventNotice.getTitle(), + eventNotice.getHitCount(), + eventNotice.isFixed(), + eventNotice.getCreatedAt(), + eventNotice.getUpdatedAt() + ); + } +} diff --git a/src/main/java/com/scg/stop/event/dto/response/EventNoticeResponse.java b/src/main/java/com/scg/stop/event/dto/response/EventNoticeResponse.java new file mode 100644 index 00000000..743d3e2e --- /dev/null +++ b/src/main/java/com/scg/stop/event/dto/response/EventNoticeResponse.java @@ -0,0 +1,44 @@ +package com.scg.stop.event.dto.response; + +import com.scg.stop.file.domain.File; +import com.scg.stop.file.dto.response.FileResponse; +import com.scg.stop.event.domain.EventNotice; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class EventNoticeResponse { + private Long id; + private String title; + private String content; + private Integer hitCount; + private boolean fixed; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private List files; + + // create a new EventNoticeResponse object + public static EventNoticeResponse from(EventNotice eventNotice, List files) { + + List fileResponses = files.stream() + .map(FileResponse::from) + .collect(java.util.stream.Collectors.toList()); + + return new EventNoticeResponse( + eventNotice.getId(), + eventNotice.getTitle(), + eventNotice.getContent(), + eventNotice.getHitCount(), + eventNotice.isFixed(), + eventNotice.getCreatedAt(), + eventNotice.getUpdatedAt(), + fileResponses + ); + } +} diff --git a/src/main/java/com/scg/stop/event/dto/response/EventPeriodResponse.java b/src/main/java/com/scg/stop/event/dto/response/EventPeriodResponse.java new file mode 100644 index 00000000..e0190ffd --- /dev/null +++ b/src/main/java/com/scg/stop/event/dto/response/EventPeriodResponse.java @@ -0,0 +1,29 @@ +package com.scg.stop.event.dto.response; + +import com.scg.stop.event.domain.EventPeriod; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class EventPeriodResponse { + + private Long id; + private Integer year; + private LocalDateTime start; + private LocalDateTime end; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public static EventPeriodResponse from(EventPeriod eventPeriod) { + return new EventPeriodResponse( + eventPeriod.getId(), + eventPeriod.getYear(), + eventPeriod.getStart(), + eventPeriod.getEnd(), + eventPeriod.getCreatedAt(), + eventPeriod.getUpdatedAt() + ); + } +} diff --git a/src/main/java/com/scg/stop/event/repository/EventNoticeRepository.java b/src/main/java/com/scg/stop/event/repository/EventNoticeRepository.java new file mode 100644 index 00000000..c7037f4f --- /dev/null +++ b/src/main/java/com/scg/stop/event/repository/EventNoticeRepository.java @@ -0,0 +1,38 @@ +package com.scg.stop.event.repository; + +import com.scg.stop.event.domain.EventNotice; +import com.scg.stop.event.dto.response.EventNoticeListElementResponse; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface EventNoticeRepository extends JpaRepository { + + @Query("SELECT n FROM EventNotice n " + + "WHERE (:searchScope IS NULL OR " + + "(:searchScope = 'content' AND (:searchTerm IS NULL OR n.content LIKE %:searchTerm%)) " + + "OR (:searchScope = 'title' AND (:searchTerm IS NULL OR n.title LIKE %:searchTerm%)) " + + "OR (:searchScope = 'both' AND (:searchTerm IS NULL OR n.title LIKE %:searchTerm% OR n.content LIKE %:searchTerm%))) " + + "AND n.fixed = false") + Page findNonFixedEventNotices( + @Param("searchTerm") String searchTerm, + @Param("searchScope") String searchScope, + Pageable pageable); + + + @Query("SELECT n FROM EventNotice n " + + "WHERE (:searchScope IS NULL OR " + + "(:searchScope = 'content' AND (:searchTerm IS NULL OR n.content LIKE %:searchTerm%)) " + + "OR (:searchScope = 'title' AND (:searchTerm IS NULL OR n.title LIKE %:searchTerm%)) " + + "OR (:searchScope = 'both' AND (:searchTerm IS NULL OR n.title LIKE %:searchTerm% OR n.content LIKE %:searchTerm%))) " + + "AND n.fixed = true") + List findFixedEventNotices( + @Param("searchTerm") String searchTerm, + @Param("searchScope") String searchScope, + Sort sort); +} diff --git a/src/main/java/com/scg/stop/event/repository/EventPeriodRepository.java b/src/main/java/com/scg/stop/event/repository/EventPeriodRepository.java new file mode 100644 index 00000000..51046d63 --- /dev/null +++ b/src/main/java/com/scg/stop/event/repository/EventPeriodRepository.java @@ -0,0 +1,12 @@ +package com.scg.stop.event.repository; + +import com.scg.stop.event.domain.EventPeriod; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface EventPeriodRepository extends JpaRepository { + + Boolean existsByYear(Integer year); + Optional findByYear(Integer year); +} diff --git a/src/main/java/com/scg/stop/event/service/EventNoticeService.java b/src/main/java/com/scg/stop/event/service/EventNoticeService.java new file mode 100644 index 00000000..26749ee9 --- /dev/null +++ b/src/main/java/com/scg/stop/event/service/EventNoticeService.java @@ -0,0 +1,153 @@ +package com.scg.stop.event.service; + +import com.scg.stop.event.domain.EventNotice; +import com.scg.stop.event.dto.request.EventNoticeRequest; +import com.scg.stop.event.dto.response.EventNoticeListElementResponse; +import com.scg.stop.event.dto.response.EventNoticeResponse; +import com.scg.stop.event.repository.EventNoticeRepository; +import com.scg.stop.file.domain.File; +import com.scg.stop.file.repository.FileRepository; +import com.scg.stop.global.exception.BadRequestException; +import com.scg.stop.global.exception.ExceptionCode; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +@Service +@Transactional +@RequiredArgsConstructor +public class EventNoticeService { + + private final EventNoticeRepository eventNoticeRepository; + private final FileRepository fileRepository; + + /** + * Create a new eventNotice + * + * @param request EventNotice Request DTO + * @return EventNotice Response DTO + */ + public EventNoticeResponse createEventNotice(EventNoticeRequest request) { + List attachedFiles = getAttachedFiles(request.getFileIds()); + EventNotice newEventNotice = EventNotice.from( + request.getTitle(), + request.getContent(), + request.isFixed(), + attachedFiles + ); + eventNoticeRepository.save(newEventNotice); + return EventNoticeResponse.from(newEventNotice, attachedFiles); + } + + /** + * Get a list of event notices. + * + * @param searchTerm Search term to filter notices (optional) + * @param searchScope Search scope to filter notices (optional) + * @param pageable Pageable + * @return Paginated list of event notices + */ + @Transactional(readOnly = true) + public Page getEventNoticeList(String searchTerm, String searchScope, Pageable pageable) { + + // If no searchTerm is provided, set searchScope to null + if (searchTerm == null || searchTerm.isEmpty()) { + searchScope = null; + } + + // Retrieve the sorting from the pageable + Sort sort = pageable.getSort(); + + // Find fixed notices with title and sorting + List fixedEventNotices = eventNoticeRepository.findFixedEventNotices(searchTerm, searchScope, sort); + + // Find non-fixed notices with title and sorting + int nonFixedEventNoticesSize = pageable.getPageSize() - fixedEventNotices.size(); + Pageable adjustedPageable = PageRequest.of(pageable.getPageNumber(), Math.max(nonFixedEventNoticesSize, 0), sort); + Page nonFixedEventNotices = eventNoticeRepository.findNonFixedEventNotices(searchTerm, searchScope, adjustedPageable); + + // Combine fixed and non-fixed notices + List combinedEventNotices = new ArrayList<>(fixedEventNotices); + combinedEventNotices.addAll(nonFixedEventNotices.getContent()); + + // Calculate the total number of elements and pages + long totalElements = fixedEventNotices.size() + nonFixedEventNotices.getTotalElements(); + int totalPages = (int) Math.ceil((double) nonFixedEventNotices.getTotalElements() / adjustedPageable.getPageSize()); + + // Return the combined result as a Page with sorting + return new PageImpl<>(combinedEventNotices, pageable, totalElements) { + @Override + public int getTotalPages() { + return totalPages; + } + + @Override + public long getTotalElements() { + return totalElements; + } + }; + } + + /** + * Get a corresponding eventNotice + * + * @param eventNoticeId ID of the eventNotice + * @return EventNotice Response DTO + */ + public EventNoticeResponse getEventNotice(Long eventNoticeId) { + EventNotice eventNotice = eventNoticeRepository.findById(eventNoticeId).orElseThrow(() -> + new BadRequestException(ExceptionCode.EVENT_NOTICE_NOT_FOUND)); + eventNotice.increaseHitCount(); + return EventNoticeResponse.from(eventNotice, eventNotice.getFiles()); + } + + /** + * Update a corresponding eventNotice + * + * @param eventNoticeId ID of the eventNotice + * @param request EventNotice Request DTO + * @return EventNotice Response DTO + */ + public EventNoticeResponse updateEventNotice(Long eventNoticeId, EventNoticeRequest request) { + EventNotice eventNotice = eventNoticeRepository.findById(eventNoticeId).orElseThrow(() -> + new BadRequestException(ExceptionCode.EVENT_NOTICE_NOT_FOUND)); + List attachedFiles = getAttachedFiles(request.getFileIds()); + + eventNotice.updateEventNotice(request.getTitle(), request.getContent(), request.isFixed(), attachedFiles); + eventNoticeRepository.save(eventNotice); + return EventNoticeResponse.from(eventNotice, attachedFiles); + } + + /** + * Delete a corresponding eventNotice + * + * @param eventNoticeId ID of the eventNotice + */ + public void deleteEventNotice(Long eventNoticeId) { + EventNotice eventNotice = eventNoticeRepository.findById(eventNoticeId).orElseThrow(() -> + new BadRequestException(ExceptionCode.EVENT_NOTICE_NOT_FOUND)); + eventNoticeRepository.delete(eventNotice); + } + + /** + * Helper method to get attached files + * + * @param fileIds List of file IDs + * @return List of files + */ + private List getAttachedFiles(List fileIds) { + if (fileIds == null || fileIds.isEmpty()) { + return List.of(); + } + List attachedFiles = fileRepository.findByIdIn(fileIds); + if (attachedFiles.size() != fileIds.size()) { + throw new BadRequestException(ExceptionCode.FILE_NOT_FOUND); + } + return attachedFiles; + } + +} \ No newline at end of file diff --git a/src/main/java/com/scg/stop/event/service/EventPeriodService.java b/src/main/java/com/scg/stop/event/service/EventPeriodService.java new file mode 100644 index 00000000..5a5d8b5b --- /dev/null +++ b/src/main/java/com/scg/stop/event/service/EventPeriodService.java @@ -0,0 +1,61 @@ +package com.scg.stop.event.service; + +import static com.scg.stop.global.exception.ExceptionCode.DUPLICATED_YEAR; +import static com.scg.stop.global.exception.ExceptionCode.INVALID_EVENT_PERIOD; +import static com.scg.stop.global.exception.ExceptionCode.NOT_FOUND_EVENT_PERIOD; + +import com.scg.stop.event.domain.EventPeriod; +import com.scg.stop.event.dto.request.EventPeriodRequest; +import com.scg.stop.event.dto.response.EventPeriodResponse; +import com.scg.stop.event.repository.EventPeriodRepository; +import com.scg.stop.global.exception.BadRequestException; +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class EventPeriodService { + + private final EventPeriodRepository eventPeriodRepository; + + public EventPeriodResponse createEventPeriod(EventPeriodRequest request) { + int currentYear = LocalDateTime.now().getYear(); + if (request.getStart().getYear() != currentYear || request.getEnd().getYear() != currentYear) { + throw new BadRequestException(INVALID_EVENT_PERIOD); + } + if (eventPeriodRepository.existsByYear(currentYear)) { + throw new BadRequestException(DUPLICATED_YEAR); + } + EventPeriod newEventPeriod = eventPeriodRepository.save(EventPeriod.of(currentYear, request.getStart(), request.getEnd())); + return EventPeriodResponse.from(newEventPeriod); + } + + @Transactional(readOnly = true) + public EventPeriodResponse getEventPeriod() { + int currentYear = LocalDateTime.now().getYear(); + EventPeriod eventPeriod = eventPeriodRepository.findByYear(currentYear) + .orElseThrow(() -> new BadRequestException(NOT_FOUND_EVENT_PERIOD)); + return EventPeriodResponse.from(eventPeriod); + } + + @Transactional(readOnly = true) + public List getEventPeriods() { + List eventPeriods = eventPeriodRepository.findAll(); + return eventPeriods.stream() + .map(EventPeriodResponse::from) + .collect(Collectors.toList()); + } + + public EventPeriodResponse updateEventPeriod(EventPeriodRequest request) { + int currentYear = LocalDateTime.now().getYear(); + EventPeriod currentEventPeriod = eventPeriodRepository.findByYear(currentYear) + .orElseThrow(() -> new BadRequestException(NOT_FOUND_EVENT_PERIOD)); + currentEventPeriod.update(request.getStart(), request.getEnd()); + return EventPeriodResponse.from(currentEventPeriod); + } +} diff --git a/src/main/java/com/scg/stop/file/config/MinioConfig.java b/src/main/java/com/scg/stop/file/config/MinioConfig.java new file mode 100644 index 00000000..28f9274a --- /dev/null +++ b/src/main/java/com/scg/stop/file/config/MinioConfig.java @@ -0,0 +1,27 @@ +package com.scg.stop.file.config; + +import io.minio.MinioClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class MinioConfig { + + @Value("${minio.access.key}") + private String accessKey; + + @Value("${minio.secret.key}") + private String secretKey; + + @Value("${minio.url}") + private String minioUrl; + + @Bean + public MinioClient minioClient() { + return MinioClient.builder() + .endpoint(minioUrl) + .credentials(accessKey, secretKey) + .build(); + } +} diff --git a/src/main/java/com/scg/stop/file/controller/FileController.java b/src/main/java/com/scg/stop/file/controller/FileController.java new file mode 100644 index 00000000..fe4ac30f --- /dev/null +++ b/src/main/java/com/scg/stop/file/controller/FileController.java @@ -0,0 +1,60 @@ +package com.scg.stop.file.controller; + +import com.scg.stop.auth.annotation.AuthUser; +import com.scg.stop.file.domain.File; +import com.scg.stop.file.dto.response.FileResponse; +import com.scg.stop.file.service.FileService; +import com.scg.stop.user.domain.AccessType; +import com.scg.stop.user.domain.User; +import lombok.RequiredArgsConstructor; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.util.UriUtils; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/files") +public class FileController { + + private final FileService fileService; + + @PostMapping + public ResponseEntity> uploadFiles(@RequestPart("files") List files) { + List responses = fileService.uploadFiles(files); + return ResponseEntity.status(HttpStatus.CREATED).body(responses); + } + + @GetMapping("/{fileId}") + public ResponseEntity getFile(@PathVariable("fileId") Long fileId) { + InputStream stream = fileService.getFile(fileId); + File file = fileService.getFileMetadata(fileId); + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(file.getMimeType())) + .body(new InputStreamResource(stream)); + } + + @GetMapping("/form/projects") + public ResponseEntity getProjectExcelForm(@AuthUser(accessType = AccessType.ADMIN) User user) { + String directoryPath = "form/"; + String fileName = "project_upload_form.xlsx"; + Resource resource = fileService.getLocalFile(directoryPath, fileName); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentDispositionFormData("attachment", UriUtils.encode(fileName, StandardCharsets.UTF_8)); + headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); + + return ResponseEntity.ok() + .headers(headers) + .body(resource); + } +} diff --git a/src/main/java/com/scg/stop/file/domain/File.java b/src/main/java/com/scg/stop/file/domain/File.java new file mode 100644 index 00000000..6b8fb02b --- /dev/null +++ b/src/main/java/com/scg/stop/file/domain/File.java @@ -0,0 +1,77 @@ +package com.scg.stop.file.domain; + +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import com.scg.stop.gallery.domain.Gallery; +import com.scg.stop.notice.domain.Notice; +import com.scg.stop.event.domain.EventNotice; +import com.scg.stop.global.domain.BaseTimeEntity; +import com.scg.stop.proposal.domain.Proposal; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = PROTECTED) +public class File extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @Column(nullable = false) + private String uuid; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String mimeType; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "notice_id") + private Notice notice; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "event_notice_id") + private EventNotice eventNotice; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "gallery_id") + private Gallery gallery; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "proposal_id") + private Proposal proposal; + static public File of(String uuid, String name, String mimeType) { + File file = new File(); + file.uuid = uuid; + file.name = name; + file.mimeType = mimeType; + return file; + } + + public void setNotice(Notice notice) { + this.notice = notice; + } + + public void setEventNotice(EventNotice eventNotice) { + this.eventNotice = eventNotice; + } + + public void setGallery(Gallery gallery) { + this.gallery = gallery; + } + + public void connectProposal(Proposal proposal) { + this.proposal = proposal; + } +} \ No newline at end of file diff --git a/src/main/java/com/scg/stop/file/dto/response/FileResponse.java b/src/main/java/com/scg/stop/file/dto/response/FileResponse.java new file mode 100644 index 00000000..0b9e02e6 --- /dev/null +++ b/src/main/java/com/scg/stop/file/dto/response/FileResponse.java @@ -0,0 +1,33 @@ +package com.scg.stop.file.dto.response; + +import static lombok.AccessLevel.PRIVATE; + +import com.scg.stop.file.domain.File; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = PRIVATE) +@AllArgsConstructor +public class FileResponse { + + private Long id; + private String uuid; + private String name; + private String mimeType; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public static FileResponse from(File file) { + return new FileResponse( + file.getId(), + file.getUuid(), + file.getName(), + file.getMimeType(), + file.getCreatedAt(), + file.getUpdatedAt() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/scg/stop/file/repository/FileRepository.java b/src/main/java/com/scg/stop/file/repository/FileRepository.java new file mode 100644 index 00000000..d2b6a996 --- /dev/null +++ b/src/main/java/com/scg/stop/file/repository/FileRepository.java @@ -0,0 +1,12 @@ +package com.scg.stop.file.repository; + +import com.scg.stop.file.domain.File; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FileRepository extends JpaRepository { + Optional findById(Long id); + List findByIdIn(List ids); +} diff --git a/src/main/java/com/scg/stop/file/service/FileService.java b/src/main/java/com/scg/stop/file/service/FileService.java new file mode 100644 index 00000000..78346fff --- /dev/null +++ b/src/main/java/com/scg/stop/file/service/FileService.java @@ -0,0 +1,73 @@ +package com.scg.stop.file.service; + +import static com.scg.stop.global.exception.ExceptionCode.FILE_NOT_FOUND; +import static com.scg.stop.global.exception.ExceptionCode.INVALID_FILE_PATH; + +import com.scg.stop.file.domain.File; +import com.scg.stop.file.dto.response.FileResponse; +import com.scg.stop.file.repository.FileRepository; +import com.scg.stop.global.exception.BadRequestException; +import com.scg.stop.global.exception.InternalServerErrorException; +import java.io.InputStream; +import java.net.MalformedURLException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.ResourceUtils; +import org.springframework.web.multipart.MultipartFile; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class FileService { + + private final FileRepository fileRepository; + + private final MinioClientService minioClientService; + + @Transactional + public List uploadFiles(List files) { + LocalDateTime now = LocalDateTime.now(); + List fileInfos = new ArrayList<>(); + for (MultipartFile file : files) { + UUID uuid = UUID.randomUUID(); + minioClientService.uploadFile(file, uuid, now); + File fileInfo = File.of(uuid.toString(), file.getOriginalFilename(), file.getContentType()); + fileInfos.add(fileInfo); + } + fileRepository.saveAll(fileInfos); + + List fileResponses = new ArrayList<>(); + for (File fileInfo : fileInfos) { + FileResponse fileResponse = FileResponse.from(fileInfo); + fileResponses.add(fileResponse); + } + return fileResponses; + } + + public InputStream getFile(Long fileId) { + File file = fileRepository.findById(fileId).orElseThrow(() -> new BadRequestException(FILE_NOT_FOUND)); + return minioClientService.getFile(file.getUuid()); + } + + public File getFileMetadata(Long fileId) { + return fileRepository.findById(fileId).orElseThrow(() -> new BadRequestException(FILE_NOT_FOUND)); + } + + public Resource getLocalFile(String directoryPath, String fileName) { + ClassPathResource file = new ClassPathResource(directoryPath + fileName); + if (!file.exists()) { + throw new InternalServerErrorException(FILE_NOT_FOUND); + } + return file; + } +} diff --git a/src/main/java/com/scg/stop/file/service/MinioClientService.java b/src/main/java/com/scg/stop/file/service/MinioClientService.java new file mode 100644 index 00000000..ffa62596 --- /dev/null +++ b/src/main/java/com/scg/stop/file/service/MinioClientService.java @@ -0,0 +1,66 @@ +package com.scg.stop.file.service; + +import com.scg.stop.global.exception.InternalServerErrorException; +import io.minio.GetObjectArgs; +import io.minio.MinioClient; +import io.minio.PutObjectArgs; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.InputStream; +import java.text.Normalizer; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import static com.scg.stop.global.exception.ExceptionCode.FAILED_TO_GET_FILE; +import static com.scg.stop.global.exception.ExceptionCode.FAILED_TO_UPLOAD_FILE; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MinioClientService { + + private final MinioClient minioClient; + + @Value("${minio.bucket.name}") + private String bucketName; + + public void uploadFile(MultipartFile file, UUID uuid, LocalDateTime createdAt) { + Map userMetadata = new HashMap<>(); + userMetadata.put("createdAt", createdAt.toString()); + userMetadata.put("originalFilename", file.getOriginalFilename()); + + try (InputStream stream = file.getInputStream()) { + minioClient.putObject( + PutObjectArgs.builder() + .bucket(bucketName) + .object(uuid.toString()).stream(stream, file.getSize(), -1) + .contentType(file.getContentType()) + .userMetadata(userMetadata) + .build() + ); + } catch (Exception e) { + log.error(e.getMessage(), e); + throw new InternalServerErrorException(FAILED_TO_UPLOAD_FILE); + } + } + + public InputStream getFile(String uuid) { + try { + String normalizedUuid = Normalizer.normalize(uuid, Normalizer.Form.NFD); // MinIO는 내부적으로 NFD 방식 사용 + return minioClient.getObject( + GetObjectArgs.builder() + .bucket(bucketName) + .object(normalizedUuid) + .build()); + } catch (Exception e) { + log.error(e.getMessage(), e); + throw new InternalServerErrorException(FAILED_TO_GET_FILE); + } + } +} diff --git a/src/main/java/com/scg/stop/gallery/controller/GalleryController.java b/src/main/java/com/scg/stop/gallery/controller/GalleryController.java new file mode 100644 index 00000000..82ede5cc --- /dev/null +++ b/src/main/java/com/scg/stop/gallery/controller/GalleryController.java @@ -0,0 +1,67 @@ +package com.scg.stop.gallery.controller; + +import com.scg.stop.auth.annotation.AuthUser; +import com.scg.stop.gallery.dto.request.GalleryRequest; +import com.scg.stop.gallery.dto.response.GalleryResponse; +import com.scg.stop.gallery.service.GalleryService; +import com.scg.stop.user.domain.AccessType; +import com.scg.stop.user.domain.User; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/galleries") +public class GalleryController { + + private final GalleryService galleryService; + + @PostMapping + public ResponseEntity createGallery( + @RequestBody @Valid GalleryRequest galleryRequest, + @AuthUser(accessType = {AccessType.ADMIN}) User user + ) { + GalleryResponse galleryResponse = galleryService.createGallery(galleryRequest); + return ResponseEntity.status(HttpStatus.CREATED).body(galleryResponse); + } + + @GetMapping + public ResponseEntity> getGalleries( + @RequestParam(value = "year", required = false) Integer year, + @RequestParam(value = "month", required = false) Integer month, + @PageableDefault(page = 0, size = 10) Pageable pageable) { + Page galleries = galleryService.getGalleries(year, month, pageable); + return ResponseEntity.ok(galleries); + } + + @GetMapping("/{galleryId}") + public ResponseEntity getGallery(@PathVariable("galleryId") Long galleryId) { + GalleryResponse galleryResponse = galleryService.getGallery(galleryId); + return ResponseEntity.ok(galleryResponse); + } + + @PutMapping("/{galleryId}") + public ResponseEntity updateGallery( + @PathVariable("galleryId") Long galleryId, + @RequestBody @Valid GalleryRequest galleryRequest, + @AuthUser(accessType = {AccessType.ADMIN}) User user + ) { + GalleryResponse galleryResponse = galleryService.updateGallery(galleryId, galleryRequest); + return ResponseEntity.ok(galleryResponse); + } + + @DeleteMapping("/{galleryId}") + public ResponseEntity deleteGallery( + @PathVariable("galleryId") Long galleryId, + @AuthUser(accessType = {AccessType.ADMIN}) User user + ) { + galleryService.deleteGallery(galleryId); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/scg/stop/gallery/domain/Gallery.java b/src/main/java/com/scg/stop/gallery/domain/Gallery.java new file mode 100644 index 00000000..d98c7a21 --- /dev/null +++ b/src/main/java/com/scg/stop/gallery/domain/Gallery.java @@ -0,0 +1,64 @@ +package com.scg.stop.gallery.domain; + +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import com.scg.stop.file.domain.File; +import com.scg.stop.global.domain.BaseTimeEntity; +import jakarta.persistence.*; + +import java.util.ArrayList; +import java.util.List; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = PROTECTED) +public class Gallery extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @Column(nullable = false) + private String title; + + @Column(nullable = false) + private Integer year; + + @Column(nullable = false) + private Integer month; + + @Column(nullable = false) + private Integer hitCount = 0; + + @OneToMany(fetch = LAZY, mappedBy = "gallery", cascade = CascadeType.ALL, orphanRemoval = true) + private List files = new ArrayList<>(); + + private Gallery(String title, Integer year, Integer month, List files) { + this.title = title; + this.year = year; + this.month = month; + this.files = files; + files.forEach(file -> file.setGallery(this)); + } + + public static Gallery of(String title, Integer year, Integer month, List files) { + return new Gallery(title, year, month, files); + } + + public void update(String title, Integer year, Integer month, List files) { + this.title = title; + this.year = year; + this.month = month; + this.files.clear(); + this.files.addAll(files); + files.forEach(file -> file.setGallery(this)); + } + + public void increaseHitCount() { + this.hitCount += 1; + } +} diff --git a/src/main/java/com/scg/stop/gallery/dto/request/GalleryRequest.java b/src/main/java/com/scg/stop/gallery/dto/request/GalleryRequest.java new file mode 100644 index 00000000..51adfeb0 --- /dev/null +++ b/src/main/java/com/scg/stop/gallery/dto/request/GalleryRequest.java @@ -0,0 +1,31 @@ +package com.scg.stop.gallery.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +import static lombok.AccessLevel.PRIVATE; + +@Getter +@NoArgsConstructor(access = PRIVATE) +@AllArgsConstructor +public class GalleryRequest { + + @NotBlank(message = "제목을 입력해주세요.") + private String title; + + @NotNull(message = "연도를 입력해주세요.") + private Integer year; + + @NotNull(message = "월을 입력해주세요.") + private Integer month; + + @Size(min = 1, message = "1개 이상의 파일을 첨부해야 합니다.") + @NotNull(message = "파일을 첨부해주세요.") + private List fileIds; +} \ No newline at end of file diff --git a/src/main/java/com/scg/stop/gallery/dto/response/GalleryResponse.java b/src/main/java/com/scg/stop/gallery/dto/response/GalleryResponse.java new file mode 100644 index 00000000..782078fb --- /dev/null +++ b/src/main/java/com/scg/stop/gallery/dto/response/GalleryResponse.java @@ -0,0 +1,48 @@ +package com.scg.stop.gallery.dto.response; + +import static lombok.AccessLevel.PRIVATE; + +import com.scg.stop.file.dto.response.FileResponse; +import com.scg.stop.gallery.domain.Gallery; +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = PRIVATE) +@AllArgsConstructor +public class GalleryResponse { + + private Long id; + private String title; + private int year; + private int month; + private int hitCount; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private List files; + + public static GalleryResponse of(Gallery gallery, List fileResponses) { + return new GalleryResponse( + gallery.getId(), + gallery.getTitle(), + gallery.getYear(), + gallery.getMonth(), + gallery.getHitCount(), + gallery.getCreatedAt(), + gallery.getUpdatedAt(), + fileResponses + ); + } + + public static GalleryResponse from(Gallery gallery) { + List fileResponses = gallery.getFiles().stream() + .map(FileResponse::from) + .collect(Collectors.toList()); + return GalleryResponse.of(gallery, fileResponses); + } +} diff --git a/src/main/java/com/scg/stop/gallery/repository/GalleryRepository.java b/src/main/java/com/scg/stop/gallery/repository/GalleryRepository.java new file mode 100644 index 00000000..a401d98a --- /dev/null +++ b/src/main/java/com/scg/stop/gallery/repository/GalleryRepository.java @@ -0,0 +1,23 @@ +package com.scg.stop.gallery.repository; + +import com.scg.stop.gallery.domain.Gallery; +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 org.springframework.stereotype.Repository; + +@Repository +public interface GalleryRepository extends JpaRepository { + + @Query("SELECT g FROM Gallery g " + + "WHERE (:year IS NULL OR g.year = :year) " + + "AND (:month IS NULL OR g.month = :month)" + + "ORDER BY g.year DESC, g.month DESC") + Page findGalleries( + @Param("year") Integer year, + @Param("month") Integer month, + Pageable pageable + ); +} diff --git a/src/main/java/com/scg/stop/gallery/service/GalleryService.java b/src/main/java/com/scg/stop/gallery/service/GalleryService.java new file mode 100644 index 00000000..111d85c4 --- /dev/null +++ b/src/main/java/com/scg/stop/gallery/service/GalleryService.java @@ -0,0 +1,76 @@ +package com.scg.stop.gallery.service; + +import static com.scg.stop.global.exception.ExceptionCode.FILE_NOT_FOUND; +import static com.scg.stop.global.exception.ExceptionCode.NOT_FOUND_GALLERY_ID; + +import com.scg.stop.file.domain.File; +import com.scg.stop.file.repository.FileRepository; +import com.scg.stop.gallery.domain.Gallery; +import com.scg.stop.gallery.dto.request.GalleryRequest; +import com.scg.stop.gallery.dto.response.GalleryResponse; +import com.scg.stop.gallery.repository.GalleryRepository; +import com.scg.stop.global.exception.BadRequestException; +import java.util.List; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class GalleryService { + + private final GalleryRepository galleryRepository; + private final FileRepository fileRepository; + + public GalleryResponse createGallery(GalleryRequest request) { + List files = fileRepository.findByIdIn(request.getFileIds()); + if (files.size() != request.getFileIds().size()) { + throw new BadRequestException(FILE_NOT_FOUND); + } + + Gallery gallery = Gallery.of(request.getTitle(), request.getYear(), request.getMonth(), files); + Gallery savedGallery = galleryRepository.save(gallery); + + return GalleryResponse.from(savedGallery); + } + + @Transactional(readOnly = true) + public Page getGalleries(Integer year, Integer month, Pageable pageable) { + Page galleries = galleryRepository.findGalleries(year, month, pageable); + return galleries.map(GalleryResponse::from); + } + + public GalleryResponse getGallery(Long galleryId) { + Gallery gallery = galleryRepository.findById(galleryId) + .orElseThrow(() -> new BadRequestException(NOT_FOUND_GALLERY_ID)); + + gallery.increaseHitCount(); + + return GalleryResponse.from(gallery); + } + + public GalleryResponse updateGallery(Long galleryId, GalleryRequest request) { + Gallery gallery = galleryRepository.findById(galleryId) + .orElseThrow(() -> new BadRequestException(NOT_FOUND_GALLERY_ID)); + + List files = fileRepository.findByIdIn(request.getFileIds()); + if (files.size() != request.getFileIds().size()) { + throw new BadRequestException(FILE_NOT_FOUND); + } + + gallery.update(request.getTitle(), request.getYear(), request.getMonth(), files); + Gallery savedGallery = galleryRepository.save(gallery); + + return GalleryResponse.from(savedGallery); + } + + public void deleteGallery(Long galleryId) { + Gallery gallery = galleryRepository.findById(galleryId) + .orElseThrow(() -> new BadRequestException(NOT_FOUND_GALLERY_ID)); + galleryRepository.delete(gallery); + } +} \ No newline at end of file diff --git a/src/main/java/com/scg/stop/global/config/AsyncConfig.java b/src/main/java/com/scg/stop/global/config/AsyncConfig.java new file mode 100644 index 00000000..8ab3d320 --- /dev/null +++ b/src/main/java/com/scg/stop/global/config/AsyncConfig.java @@ -0,0 +1,31 @@ +package com.scg.stop.global.config; + +import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.AsyncConfigurer; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +@Configuration +@EnableAsync +public class AsyncConfig implements AsyncConfigurer { + + @Override + @Bean(name = "mailExecutor") + public Executor getAsyncExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(10); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("Async MailExecutor"); + return executor; + } + + @Override + public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { + return AsyncConfigurer.super.getAsyncUncaughtExceptionHandler(); + } +} diff --git a/src/main/java/com/scg/stop/global/config/EmailServiceConfig.java b/src/main/java/com/scg/stop/global/config/EmailServiceConfig.java new file mode 100644 index 00000000..becafa15 --- /dev/null +++ b/src/main/java/com/scg/stop/global/config/EmailServiceConfig.java @@ -0,0 +1,22 @@ +package com.scg.stop.global.config; + +import com.scg.stop.global.infrastructure.EmailService; +import com.scg.stop.global.infrastructure.TransactionalEmailService; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class EmailServiceConfig { + + private final ApplicationEventPublisher applicationEventPublisher; + + public EmailServiceConfig(ApplicationEventPublisher applicationEventPublisher) { + this.applicationEventPublisher = applicationEventPublisher; + } + + @Bean + public EmailService emailService() { + return new TransactionalEmailService(applicationEventPublisher); + } +} \ No newline at end of file diff --git a/src/main/java/com/scg/stop/global/config/EnvChecker.java b/src/main/java/com/scg/stop/global/config/EnvChecker.java new file mode 100644 index 00000000..02c2d7c6 --- /dev/null +++ b/src/main/java/com/scg/stop/global/config/EnvChecker.java @@ -0,0 +1,17 @@ +package com.scg.stop.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +@Component +public class EnvChecker implements CommandLineRunner { + + @Value("${spring.datasource.url}") + private String dbHost; + + @Override + public void run(String... args) { + System.out.println("DB_HOST = " + dbHost); + } +} diff --git a/src/main/java/com/scg/stop/global/config/JpaAuditingConfig.java b/src/main/java/com/scg/stop/global/config/JpaAuditingConfig.java new file mode 100644 index 00000000..1d14b394 --- /dev/null +++ b/src/main/java/com/scg/stop/global/config/JpaAuditingConfig.java @@ -0,0 +1,9 @@ +package com.scg.stop.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@EnableJpaAuditing +@Configuration +public class JpaAuditingConfig { +} diff --git a/src/main/java/com/scg/stop/global/config/MailConfig.java b/src/main/java/com/scg/stop/global/config/MailConfig.java new file mode 100644 index 00000000..2d04058b --- /dev/null +++ b/src/main/java/com/scg/stop/global/config/MailConfig.java @@ -0,0 +1,69 @@ +package com.scg.stop.global.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; + +import java.util.Properties; + +@Configuration +@RequiredArgsConstructor +public class MailConfig { + private static final String MAIL_SMTP_AUTH = "mail.smtp.auth"; + private static final String MAIL_DEBUG = "mail.smtp.debug"; + private static final String MAIL_CONNECTION_TIMEOUT = "mail.smtp.connectiontimeout"; + private static final String MAIL_SMTP_STARTTLS_ENABLE = "mail.smtp.starttls.enable"; + + // SMTP 서버 + @Value("${spring.mail.host}") + private String host; + + // 계정 + @Value("${spring.mail.username}") + private String username; + + // 비밀번호 + @Value("${spring.mail.password}") + private String password; + + // 포트번호 + @Value("${spring.mail.port}") + private int port; + + @Value("${spring.mail.properties.mail.smtp.auth}") + private boolean auth; + + @Value("${spring.mail.properties.mail.smtp.debug}") + private boolean debug; + + @Value("${spring.mail.properties.mail.smtp.timeout}") + private int connectionTimeout; + + @Value("${spring.mail.properties.mail.smtp.starttls.enable}") + private boolean startTlsEnable; + + @Bean + public JavaMailSender javaMailSender() { + JavaMailSenderImpl javaMailSender = new JavaMailSenderImpl(); + javaMailSender.setHost(host); + javaMailSender.setUsername(username); + javaMailSender.setPassword(password); + javaMailSender.setPort(port); + + Properties properties = javaMailSender.getJavaMailProperties(); + properties.put(MAIL_SMTP_AUTH, auth); + properties.put(MAIL_DEBUG, debug); + properties.put(MAIL_CONNECTION_TIMEOUT, connectionTimeout); + properties.put(MAIL_SMTP_STARTTLS_ENABLE, startTlsEnable); + + javaMailSender.setJavaMailProperties(properties); + javaMailSender.setDefaultEncoding("UTF-8"); + + return javaMailSender; + } + + +} \ No newline at end of file diff --git a/src/main/java/com/scg/stop/global/config/QueryDslConfig.java b/src/main/java/com/scg/stop/global/config/QueryDslConfig.java new file mode 100644 index 00000000..c36fb22f --- /dev/null +++ b/src/main/java/com/scg/stop/global/config/QueryDslConfig.java @@ -0,0 +1,18 @@ +package com.scg.stop.global.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QueryDslConfig { + + @PersistenceContext + private EntityManager entityManager; + @Bean + public JPAQueryFactory queryFactory() { + return new JPAQueryFactory(entityManager); + } +} diff --git a/src/main/java/com/scg/stop/global/config/RestTemplateConfig.java b/src/main/java/com/scg/stop/global/config/RestTemplateConfig.java new file mode 100644 index 00000000..66f83f2c --- /dev/null +++ b/src/main/java/com/scg/stop/global/config/RestTemplateConfig.java @@ -0,0 +1,19 @@ +package com.scg.stop.global.config; + +import java.time.Duration; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplateBuilder() + .setConnectTimeout(Duration.ofSeconds(10)) + .setReadTimeout(Duration.ofSeconds(10)) + .build(); + } +} diff --git a/src/main/java/com/scg/stop/global/config/WebConfig.java b/src/main/java/com/scg/stop/global/config/WebConfig.java new file mode 100644 index 00000000..7429f485 --- /dev/null +++ b/src/main/java/com/scg/stop/global/config/WebConfig.java @@ -0,0 +1,29 @@ +package com.scg.stop.global.config; + +import com.scg.stop.auth.config.AuthUserArgumentResolver; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +@RequiredArgsConstructor +@Configuration +public class WebConfig implements WebMvcConfigurer { + + private final AuthUserArgumentResolver authUserArgumentResolver; + + @Override + public void addCorsMappings(CorsRegistry corsRegistry) { + corsRegistry.addMapping("/**") + .allowedOriginPatterns("*") + .allowedMethods("*") + .allowedHeaders("*") + .allowCredentials(true); + } + + @Override + public void addArgumentResolvers(List argumentResolvers) { + argumentResolvers.add(authUserArgumentResolver); + } +} diff --git a/src/main/java/com/scg/stop/global/domain/BaseTimeEntity.java b/src/main/java/com/scg/stop/global/domain/BaseTimeEntity.java new file mode 100644 index 00000000..52973efa --- /dev/null +++ b/src/main/java/com/scg/stop/global/domain/BaseTimeEntity.java @@ -0,0 +1,28 @@ +package com.scg.stop.global.domain; + +import static lombok.AccessLevel.PROTECTED; +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import java.time.LocalDateTime; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Getter +@MappedSuperclass +@NoArgsConstructor(access = PROTECTED) +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseTimeEntity { + + @CreatedDate + @Column(updatable = false, nullable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(nullable = false) + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/scg/stop/global/domain/EmailEvent.java b/src/main/java/com/scg/stop/global/domain/EmailEvent.java new file mode 100644 index 00000000..fc734b84 --- /dev/null +++ b/src/main/java/com/scg/stop/global/domain/EmailEvent.java @@ -0,0 +1,19 @@ +package com.scg.stop.global.domain; + +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +@Getter +public class EmailEvent extends ApplicationEvent { + + private final String recipient; + private final String subject; + private final String body; + + public EmailEvent(Object source, String recipient, String subject, String body) { + super(source); + this.recipient = recipient; + this.subject = subject; + this.body = body; + } +} \ No newline at end of file diff --git a/src/main/java/com/scg/stop/global/eventListener/EmailEventListener.java b/src/main/java/com/scg/stop/global/eventListener/EmailEventListener.java new file mode 100644 index 00000000..a157bb93 --- /dev/null +++ b/src/main/java/com/scg/stop/global/eventListener/EmailEventListener.java @@ -0,0 +1,37 @@ +package com.scg.stop.global.eventListener; + + +import com.scg.stop.global.domain.EmailEvent; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@RequiredArgsConstructor +@Component +@Slf4j +public class EmailEventListener { + + private final JavaMailSender mailSender; + + @Async("mailExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void listen(EmailEvent event) { + try { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true); + helper.setTo(event.getRecipient()); + helper.setSubject(event.getSubject()); + helper.setText(event.getBody(), true); + mailSender.send(message); + } catch (Exception e) { +// e.printStackTrace(); + log.debug("TransactionalEmailSend Error: {}", e.toString()); + } + } +} diff --git a/src/main/java/com/scg/stop/global/excel/Excel.java b/src/main/java/com/scg/stop/global/excel/Excel.java new file mode 100644 index 00000000..600a5011 --- /dev/null +++ b/src/main/java/com/scg/stop/global/excel/Excel.java @@ -0,0 +1,22 @@ +package com.scg.stop.global.excel; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.xssf.streaming.SXSSFWorkbook; + +import java.io.IOException; +import java.io.OutputStream; + +@AllArgsConstructor +@Getter +public class Excel { + public String filename; + public SXSSFWorkbook data; + + public void write(OutputStream out) throws IOException { + this.data.write(out); + this.data.dispose(); + this.data.close(); + } +} diff --git a/src/main/java/com/scg/stop/global/excel/ExcelUtil.java b/src/main/java/com/scg/stop/global/excel/ExcelUtil.java new file mode 100644 index 00000000..22c640ee --- /dev/null +++ b/src/main/java/com/scg/stop/global/excel/ExcelUtil.java @@ -0,0 +1,115 @@ +package com.scg.stop.global.excel; + +import com.scg.stop.global.excel.annotation.ExcelColumn; +import com.scg.stop.global.excel.annotation.ExcelDownload; +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.xssf.streaming.SXSSFSheet; +import org.apache.poi.xssf.streaming.SXSSFWorkbook; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Field; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; + +@Component +public class ExcelUtil { + public static final int MAX_ROW_ACCESS_SIZE = 10000; + + public Excel toExcel(String filename, SXSSFWorkbook workbook) { + return new Excel(filename, workbook); + } + + + public String getFilename(Workbook workbook, Class clazz) { + LocalDateTime time = LocalDateTime.now(); + if (workbook instanceof SXSSFWorkbook) { + return String.format("%s-%s.xlsx", clazz.getDeclaredAnnotation(ExcelDownload.class).fileName(), time); + } + return String.format("%s-%s.xls", clazz.getDeclaredAnnotation(ExcelDownload.class).fileName(), time); + } + + public SXSSFWorkbook createExcel(List lists, Class clazz) { + SXSSFWorkbook workbook = createWorkBook(); + workbook.setCompressTempFiles(true); + SXSSFSheet sheet = workbook.createSheet(clazz.getAnnotation(ExcelDownload.class).sheetName()); + int rowNum = 0; + Row row = sheet.createRow(rowNum++); + List headers = Arrays.stream(clazz.getDeclaredFields()) + .filter(field -> field.isAnnotationPresent(ExcelColumn.class)) + .map(field -> field.getAnnotation(ExcelColumn.class).headerName()) + .toList(); + renderHeaderRow(row, headers); + for (T data : lists) { + row = sheet.createRow(rowNum++); + renderBodyRow(row, data, clazz); + } + return workbook; + } + + public SXSSFWorkbook append(SXSSFWorkbook workbook, List lists, Class clazz) { + SXSSFSheet sheet = workbook.getSheet( + clazz.getAnnotation(ExcelDownload.class).sheetName() + ); + int rowNum = sheet.getLastRowNum() + 1; + Row row; + for (T data : lists) { + row = sheet.createRow(rowNum++); + renderBodyRow(row, data, clazz); + } + return workbook; + } + + private SXSSFWorkbook createWorkBook() { + return new SXSSFWorkbook(MAX_ROW_ACCESS_SIZE); + } + + private void renderHeaderRow(Row firstRow, List headers) { + int cellIdx = 0; + for (String header : headers) { + Cell headerCell = firstRow.createCell(cellIdx++); + setHeaderCellStyle(headerCell); + renderCellValue(headerCell, header); + } + } + + private void renderBodyRow(Row row, T data, Class clazz) { + Field[] fields = clazz.getDeclaredFields(); + int cellIdx = 0; + for (Field field : fields) { + Cell cell = row.createCell(cellIdx++); + Object value; + field.setAccessible(true); + try { + value = field.get(data); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + field.setAccessible(false); + renderCellValue(cell, value); + } + } + + private void renderCellValue(Cell cell, Object value) { + if (value instanceof Number num) { + cell.setCellValue(num.doubleValue()); + return; + } + cell.setCellValue(value == null ? "" : value.toString()); + } + + private void setHeaderCellStyle(Cell cell) { + Workbook workbook = cell.getSheet().getWorkbook(); + CellStyle cellStyle = workbook.createCellStyle(); + + Font font = workbook.createFont(); + font.setBold(true); + cellStyle.setFont(font); + + cellStyle.setFillForegroundColor(IndexedColors.LIGHT_GREEN.getIndex()); + cellStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); + cellStyle.setAlignment(HorizontalAlignment.CENTER); + + cell.setCellStyle(cellStyle); + } +} diff --git a/src/main/java/com/scg/stop/global/excel/annotation/ExcelColumn.java b/src/main/java/com/scg/stop/global/excel/annotation/ExcelColumn.java new file mode 100644 index 00000000..8e145723 --- /dev/null +++ b/src/main/java/com/scg/stop/global/excel/annotation/ExcelColumn.java @@ -0,0 +1,12 @@ +package com.scg.stop.global.excel.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +public @interface ExcelColumn { + String headerName() default ""; +} diff --git a/src/main/java/com/scg/stop/global/excel/annotation/ExcelDownload.java b/src/main/java/com/scg/stop/global/excel/annotation/ExcelDownload.java new file mode 100644 index 00000000..5cb2ee65 --- /dev/null +++ b/src/main/java/com/scg/stop/global/excel/annotation/ExcelDownload.java @@ -0,0 +1,14 @@ +package com.scg.stop.global.excel.annotation; + + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface ExcelDownload { + String fileName() default ""; + String sheetName() default "Sheet 1"; +} diff --git a/src/main/java/com/scg/stop/global/exception/BadRequestException.java b/src/main/java/com/scg/stop/global/exception/BadRequestException.java new file mode 100644 index 00000000..e7376128 --- /dev/null +++ b/src/main/java/com/scg/stop/global/exception/BadRequestException.java @@ -0,0 +1,19 @@ +package com.scg.stop.global.exception; +import lombok.Getter; + +@Getter +public class BadRequestException extends RuntimeException { + + private final int code; + private final String message; + + public BadRequestException(final ExceptionCode exceptionCode) { + this.code = exceptionCode.getCode(); + this.message = exceptionCode.getMessage(); + } + + public BadRequestException(final ExceptionCode exceptionCode, final String customMessage) { + this.code = exceptionCode.getCode(); + this.message = customMessage; + } +} diff --git a/src/main/java/com/scg/stop/global/exception/ExceptionCode.java b/src/main/java/com/scg/stop/global/exception/ExceptionCode.java new file mode 100644 index 00000000..0c7dfc45 --- /dev/null +++ b/src/main/java/com/scg/stop/global/exception/ExceptionCode.java @@ -0,0 +1,109 @@ +package com.scg.stop.global.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ExceptionCode { + + INVALID_REQUEST(1000, "요청 형식이 올바르지 않습니다."), + + // event domain + DUPLICATED_YEAR(1001, "해당 연도의 행사 기간이 이미 존재합니다."), + NOT_FOUND_EVENT_PERIOD(1002, "올해의 이벤트 기간이 존재하지 않습니다."), + INVALID_EVENT_PERIOD(1003, "이벤트 시작 일시 혹은 종료 일시가 올해를 벗어났습니다."), + + // auth domain + UNABLE_TO_GET_USER_INFO(2001, "소셜 로그인 공급자로부터 유저 정보를 받아올 수 없습니다."), + UNABLE_TO_GET_ACCESS_TOKEN(2002, "소셜 로그인 공급자로부터 인증 토큰을 받아올 수 없습니다."), + UNAUTHORIZED_ACCESS(3000, "접근할 수 없는 리소스입니다."), + INVALID_REFRESH_TOKEN(3001, "유효하지 않은 Refresh Token 입니다."), + FAILED_TO_VALIDATE_TOKEN(3002, "토큰 검증에 실패했습니다."), + INVALID_ACCESS_TOKEN(3003, "유효하지 않은 Access Token 입니다."), + + // notion domain + FAILED_TO_FETCH_NOTION_DATA(13000, "Notion 데이터를 가져오는데 실패했습니다."), + + + // user domain + NOT_FOUND_USER_ID(4000, "유저 id 를 찾을 수 없습니다."), + REGISTER_NOT_FINISHED(4001, "회원가입이 필요합니다."), + NOT_AUTHORIZED(4002, "유저 권한이 존재하지 않습니다."), + NOT_FOUND_DEPARTMENT(4003, "학과가 존재하지 않습니다."), + INVALID_STUDENTINFO(4004, "학과/학번 정보가 존재하지 않습니다."), + INVALID_USERTYPE(4005, "회원 가입 이용이 불가능한 회원 유형입니다."), + NOT_FOUND_APPLICATION_ID(4010, "ID에 해당하는 인증 신청 정보가 존재하지 않습니다."), + ALREADY_VERIFIED_USER(4011, "이미 인증 된 회원입니다."), + UNABLE_TO_EDIT_USER_TYPE(4100, "회원 유형은 수정할 수 없습니다."), + DIVISION_OR_POSITION_REQUIRED(4101, "소속 또는 직책의 형식이 잘못되었습니다."), + + // project domain + NOT_FOUND_PROJECT(77000, "프로젝트를 찾을 수 없습니다."), + NOT_FOUND_PROJECT_THUMBNAIL(77001, "프로젝트 썸네일을 찾을 수 없습니다"), + NOT_FOUND_PROJECT_POSTER(77002, "프로젝트 포스터를 찾을 수 없습니다"), + INVALID_MEMBER(77003, "멤버 정보가 올바르지 않습니다."), + INVALID_TECHSTACK(77004, "기술 스택 정보가 올바르지 않습니다."), + ALREADY_FAVORITE_PROJECT(77005, "관심 표시한 프로젝트가 이미 존재합니다"), + NOT_FOUND_FAVORITE_PROJECT(77007, "관심 표시한 프로젝트를 찾을 수 없습니다."), + ALREADY_LIKE_PROJECT(77008, "이미 좋아요 한 프로젝트입니다."), + NOT_FOUND_LIKE_PROJECT(77009, "좋아요 표시한 프로젝트가 존재하지 않습니다"), + NOT_FOUND_COMMENT(77010, "댓글을 찾을 수 없습니다"), + NOT_MATCH_USER(77011, "유저 정보가 일치하지 않습니다"), + + // project excel + INVALID_FILE_SIZE(78000, "엑셀 행의 개수와 업로드한 이미지의 개수가 일치해야합니다."), + INVALID_THUMBNAIL_NAME(78001, "썸네일 이미지 이름을 찾을 수 없습니다."), + INVALID_POSTER_NAME(78002, "포스터 이미지 이름을 찾을 수 없습니다."), + INVALID_AWARD_STATUS_KOREAN_NAME(78003, "수상 내역의 한글 이름이 올바르지 않습니다."), + INVALID_PROJECT_TYPE_KOREAN_NAME(78004, "프로젝트 종류의 한글 이름이 올바르지 않습니다."), + INVALID_PROJECT_CATEGORY_KOREAN_NAME(78005, "프로젝트 분야의 한글 이름이 올바르지 않습니다."), + EMPTY_CELL(78006, "모든 셀은 값이 있어야 합니다."), + INVALID_EXCEL_FORMAT(78007, "엑셀 형식이 올바르지 않습니다."), + DUPLICATE_THUMBNAIL_ID(78008, "중복된 썸네일 이미지 이름이 존재합니다."), + DUPLICATE_POSTER_ID(78009, "중복된 포스터 이미지 이름이 존재합니다."), + INVALID_YEAR_FORMAT(780010, "프로젝트 년도는 숫자만 입력해야 합니다."), + INVALID_EXCEL(780011, "엑셀 파일을 열 수 없습니다."), + + // file domain + FAILED_TO_UPLOAD_FILE(5000, "파일 업로드를 실패했습니다."), + FAILED_TO_GET_FILE(5001, "파일 가져오기를 실패했습니다."), + FILE_NOT_FOUND(5002, "요청한 ID에 해당하는 파일이 존재하지 않습니다."), + NOT_FOUND_FILE_ID(5002, "요청한 ID에 해당하는 파일이 존재하지 않습니다."), + INVALID_FILE_PATH(5004, "파일을 찾을 수 없습니다."), + + // notice domain + NOTICE_NOT_FOUND(10000, "요청한 ID에 해당하는 공지사항이 존재하지 않습니다."), + EVENT_NOTICE_NOT_FOUND(11000, "요청한 ID에 해당하는 이벤트가 존재하지 않습니다."), + + // inquiry domain + NOT_FOUND_INQUIRY(8000, "요청한 ID에 해당하는 문의가 존재하지 않습니다."), + NOT_FOUND_INQUIRY_REPLY(8001, "요청한 ID에 해당하는 문의 답변이 존재하지 않습니다."), + ALREADY_EXIST_INQUIRY_REPLY(8002, "이미 답변이 등록된 문의입니다."), + UNAUTHORIZED_USER(8003, "해당 문의에 대한 권한이 없습니다."), + + // video domain + ID_NOT_FOUND(8200, "해당 ID에 해당하는 잡페어 인터뷰가 없습니다."), + TALK_ID_NOT_FOUND(8400, "해당 ID에 해당하는 대담 영상이 없습니다."), + NO_QUIZ(8401, "퀴즈 데이터가 존재하지 않습니다."), + NOT_FOUND_USER_QUIZ(8402, "퀴즈 제출 데이터가 존재하지 않습니다."), + ALREADY_QUIZ_SUCCESS(8601, "이미 퀴즈의 정답을 모두 맞추었습니다."), + ALREADY_FAVORITE(8801, "이미 관심 리스트에 추가되었습니다."), + NOT_FAVORITE(8802, "이미 관심 리스트에 추가되어 있지 않습니다."), + NOT_EVENT_PERIOD(8804, "퀴즈 이벤트 참여 기간이 아닙니다."), + MISMATCH_CURRENT_YEAR(8805, "대담 영상과 현재 이벤트 참여 연도가 일치하지 않습니다."), + TOO_MANY_TRY_QUIZ(8901, "퀴즈 최대 시도 횟수를 초과하였습니다."), + + // excel + NOT_COMPATIBLE_EXCEL(71001, "엑셀 파일이 주어진 클래스와 호환되지 않습니다."), + + // gallery domain + NOT_FOUND_GALLERY_ID(9001, "요청한 ID에 해당하는 갤러리가 존재하지 않습니다."), + + // proposal domain + NOT_FOUND_PROPOSAL(6000, "요청한 ID에 해당하는 과제제안이 존재하지 않습니다."), + NOT_FOUND_PROPOSALREPLY(6001, "요청한 ID에 해당하는 과제제안 답변이 존재하지 않습니다."); + + private final int code; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/com/scg/stop/global/exception/ExceptionResponse.java b/src/main/java/com/scg/stop/global/exception/ExceptionResponse.java new file mode 100644 index 00000000..21160601 --- /dev/null +++ b/src/main/java/com/scg/stop/global/exception/ExceptionResponse.java @@ -0,0 +1,12 @@ +package com.scg.stop.global.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class ExceptionResponse { + + private final int code; + private final String message; +} diff --git a/src/main/java/com/scg/stop/global/exception/GlobalExceptionHandler.java b/src/main/java/com/scg/stop/global/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..349dc277 --- /dev/null +++ b/src/main/java/com/scg/stop/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,89 @@ +package com.scg.stop.global.exception; + +import static com.scg.stop.global.exception.ExceptionCode.INVALID_REQUEST; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +@RestControllerAdvice +@Slf4j +public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + + @Override + protected ResponseEntity handleMethodArgumentNotValid( + MethodArgumentNotValidException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + + log.warn(ex.getMessage(), ex); + + Map body = new HashMap<>(); + List errors = ex.getBindingResult().getFieldErrors() + .stream() + .map(e -> e.getDefaultMessage()) + .collect(Collectors.toList()); + body.put("details", errors); + body.put("code", INVALID_REQUEST.getCode()); + body.put("message", INVALID_REQUEST.getMessage()); + + return ResponseEntity.badRequest().body(body); + } + + @ExceptionHandler(BadRequestException.class) + public ResponseEntity handleBadRequestException(BadRequestException ex) { + + log.warn(ex.getMessage(), ex); + + return ResponseEntity.badRequest() + .body(new ExceptionResponse(ex.getCode(), ex.getMessage())); + } + + @ExceptionHandler(InvalidJwtException.class) + public ResponseEntity handleInvalidJwtException(InvalidJwtException ex) { + + log.warn(ex.getMessage(), ex); + + return ResponseEntity.badRequest() + .body(new ExceptionResponse(ex.getCode(), ex.getMessage())); + } + + @ExceptionHandler(SocialLoginException.class) + public ResponseEntity handleSocialLoginException(SocialLoginException ex) { + + log.warn(ex.getMessage(), ex); + + return ResponseEntity.badRequest() + .body(new ExceptionResponse(ex.getCode(), ex.getMessage())); + } + + @ExceptionHandler(UnauthorizedException.class) + public ResponseEntity handleUnauthorizedException(UnauthorizedException ex) { + + log.warn(ex.getMessage(), ex); + + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(new ExceptionResponse(ex.getCode(), ex.getMessage())); + } + + @ExceptionHandler(InternalServerErrorException.class) + public ResponseEntity handleInternalServerErrorException(InternalServerErrorException ex) { + + log.error(ex.getMessage(), ex); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ExceptionResponse(ex.getCode(), ex.getMessage())); + } +} \ No newline at end of file diff --git a/src/main/java/com/scg/stop/global/exception/InternalServerErrorException.java b/src/main/java/com/scg/stop/global/exception/InternalServerErrorException.java new file mode 100644 index 00000000..d96f93af --- /dev/null +++ b/src/main/java/com/scg/stop/global/exception/InternalServerErrorException.java @@ -0,0 +1,15 @@ +package com.scg.stop.global.exception; + +import lombok.Getter; + +@Getter +public class InternalServerErrorException extends RuntimeException { + + private final int code; + private final String message; + + public InternalServerErrorException(final ExceptionCode exceptionCode) { + this.code = exceptionCode.getCode(); + this.message = exceptionCode.getMessage(); + } +} diff --git a/src/main/java/com/scg/stop/global/exception/InvalidJwtException.java b/src/main/java/com/scg/stop/global/exception/InvalidJwtException.java new file mode 100644 index 00000000..8cd63685 --- /dev/null +++ b/src/main/java/com/scg/stop/global/exception/InvalidJwtException.java @@ -0,0 +1,14 @@ +package com.scg.stop.global.exception; + +import lombok.Getter; + +@Getter +public class InvalidJwtException extends RuntimeException{ + private final int code; + private final String message; + + public InvalidJwtException(ExceptionCode exceptionCode) { + this.code = exceptionCode.getCode(); + this.message = exceptionCode.getMessage(); + } +} diff --git a/src/main/java/com/scg/stop/global/exception/SocialLoginException.java b/src/main/java/com/scg/stop/global/exception/SocialLoginException.java new file mode 100644 index 00000000..d3fe5674 --- /dev/null +++ b/src/main/java/com/scg/stop/global/exception/SocialLoginException.java @@ -0,0 +1,14 @@ +package com.scg.stop.global.exception; + +import lombok.Getter; + +@Getter +public class SocialLoginException extends RuntimeException{ + private final int code; + private final String message; + + public SocialLoginException(ExceptionCode exceptionCode) { + this.code = exceptionCode.getCode(); + this.message = exceptionCode.getMessage(); + } +} diff --git a/src/main/java/com/scg/stop/global/exception/UnauthorizedException.java b/src/main/java/com/scg/stop/global/exception/UnauthorizedException.java new file mode 100644 index 00000000..a37c7335 --- /dev/null +++ b/src/main/java/com/scg/stop/global/exception/UnauthorizedException.java @@ -0,0 +1,14 @@ +package com.scg.stop.global.exception; + +import lombok.Getter; + +@Getter +public class UnauthorizedException extends RuntimeException { + + private final int code; + private final String message; + public UnauthorizedException(ExceptionCode exceptionCode) { + code = exceptionCode.getCode(); + message = exceptionCode.getMessage(); + } +} diff --git a/src/main/java/com/scg/stop/global/infrastructure/EmailService.java b/src/main/java/com/scg/stop/global/infrastructure/EmailService.java new file mode 100644 index 00000000..6add437c --- /dev/null +++ b/src/main/java/com/scg/stop/global/infrastructure/EmailService.java @@ -0,0 +1,6 @@ +package com.scg.stop.global.infrastructure; + + +public interface EmailService { + void sendEmail(String recipient, String subject, String body); +} \ No newline at end of file diff --git a/src/main/java/com/scg/stop/global/infrastructure/TransactionalEmailService.java b/src/main/java/com/scg/stop/global/infrastructure/TransactionalEmailService.java new file mode 100644 index 00000000..b07d4731 --- /dev/null +++ b/src/main/java/com/scg/stop/global/infrastructure/TransactionalEmailService.java @@ -0,0 +1,16 @@ +package com.scg.stop.global.infrastructure; + +import com.scg.stop.global.domain.EmailEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; + +@RequiredArgsConstructor +public class TransactionalEmailService implements EmailService { + + private final ApplicationEventPublisher applicationEventPublisher; + + @Override + public void sendEmail(String recipient, String subject, String body) { + applicationEventPublisher.publishEvent(new EmailEvent(this, recipient, subject, body)); + } +} \ No newline at end of file diff --git a/src/main/java/com/scg/stop/global/validation/EnumValidator.java b/src/main/java/com/scg/stop/global/validation/EnumValidator.java new file mode 100644 index 00000000..a8958344 --- /dev/null +++ b/src/main/java/com/scg/stop/global/validation/EnumValidator.java @@ -0,0 +1,28 @@ +package com.scg.stop.global.validation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class EnumValidator implements ConstraintValidator { + private ValidEnum annotation; + + @Override + public void initialize(ValidEnum constraintAnnotation) { + this.annotation = constraintAnnotation; + } + + @Override + public boolean isValid(Enum value, ConstraintValidatorContext context) { + boolean result = false; + Object[] enumValues = this.annotation.enumClass().getEnumConstants(); + if (enumValues != null) { + for (Object enumValue : enumValues) { + if (value == enumValue) { + result = true; + break; + } + } + } + return result; + } +} \ No newline at end of file diff --git a/src/main/java/com/scg/stop/global/validation/ValidEnum.java b/src/main/java/com/scg/stop/global/validation/ValidEnum.java new file mode 100644 index 00000000..213c317e --- /dev/null +++ b/src/main/java/com/scg/stop/global/validation/ValidEnum.java @@ -0,0 +1,19 @@ +package com.scg.stop.global.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Constraint(validatedBy = EnumValidator.class) +@Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidEnum { + String message() default "허용되지 않는 enum 값입니다."; + Class[] groups() default {}; + Class[] payload() default {}; + Class> enumClass(); +} diff --git a/src/main/java/com/scg/stop/jobInfo/controller/JobInfoController.java b/src/main/java/com/scg/stop/jobInfo/controller/JobInfoController.java new file mode 100644 index 00000000..b2787f6c --- /dev/null +++ b/src/main/java/com/scg/stop/jobInfo/controller/JobInfoController.java @@ -0,0 +1,34 @@ +package com.scg.stop.jobInfo.controller; + +import com.scg.stop.jobInfo.dto.request.JobInfoRequest; +import com.scg.stop.jobInfo.dto.response.JobInfoResponse; +import com.scg.stop.jobInfo.service.JobInfoService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +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("/jobInfos") +@RequiredArgsConstructor +public class JobInfoController { + + private final JobInfoService jobInfoService; + + // Get the paginated list of jobInfos + @PostMapping + public ResponseEntity> getJobInfos( + @RequestBody JobInfoRequest request, + @PageableDefault(page = 0, size = 10) Pageable pageable) { + Page jobInfos = jobInfoService.getJobInfos(request, pageable); + return ResponseEntity.status(HttpStatus.OK).body(jobInfos); + } + + +} diff --git a/src/main/java/com/scg/stop/jobInfo/dto/request/JobInfoRequest.java b/src/main/java/com/scg/stop/jobInfo/dto/request/JobInfoRequest.java new file mode 100644 index 00000000..7102c135 --- /dev/null +++ b/src/main/java/com/scg/stop/jobInfo/dto/request/JobInfoRequest.java @@ -0,0 +1,22 @@ +package com.scg.stop.jobInfo.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +// TODO: Define what fields to query +public class JobInfoRequest { + + private String company; // 기업명 : text + private List jobTypes; // 고용 형태 : multiselect + private String region; // 근무 지역 : text + private String position; // 채용 포지션 : text + private String hiringTime; // 채용 시점 : text + private List state; // 채용 상태 : multiselect + +} diff --git a/src/main/java/com/scg/stop/jobInfo/dto/response/JobInfoResponse.java b/src/main/java/com/scg/stop/jobInfo/dto/response/JobInfoResponse.java new file mode 100644 index 00000000..4a4dd627 --- /dev/null +++ b/src/main/java/com/scg/stop/jobInfo/dto/response/JobInfoResponse.java @@ -0,0 +1,135 @@ +package com.scg.stop.jobInfo.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class JobInfoResponse { + private String object; + private String id; // 페이지 id + private String company; // 기업명 + private List jobTypes; // 고용 형태 + private String region; // 근무 지역 + private String position; // 채용 포지션 + private String logo; // logo + private String salary; // 연봉 + private String website; // 웹사이트 + private List state; // 채용 상태 + private String hiringTime; // 채용 시점 + private String url; // redirect url + + @JsonProperty("object") + public void setObject(String object) { + this.object = object; + } + + @JsonProperty("id") + public void setId(String id) { + this.id = id; + } + + @JsonProperty("properties") + public void setProperties(JsonNode properties) { + if (properties != null) { + this.company = extractTextFromProperty(properties, "기업명", "title"); + this.jobTypes = extractMultiSelect(properties, "고용 형태"); + this.region = extractTextFromProperty(properties, "근무 지역", "rich_text"); + this.position = extractTextFromProperty(properties, "채용 포지션", "rich_text"); + this.logo = extractLogoUrl(properties, "logo"); + this.salary = extractTextFromProperty(properties, "연봉", "rich_text"); + this.website = extractUrl(properties, "웹사이트"); + this.state = extractMultiSelect(properties, "채용 상태"); + this.hiringTime = extractTextFromProperty(properties, "채용 시점", "rich_text"); + } + } + + @JsonProperty("url") + public void setUrl(String url) { + this.url = url; + } + + // Helper methods + // Extract text from the text property + private String extractTextFromProperty(JsonNode properties, String fieldName, String textType) { + if (properties.has(fieldName)) { + JsonNode fieldNode = properties.get(fieldName).get(textType); + if (fieldNode.isArray() && fieldNode.size() > 0) { + return fieldNode.get(0).get("text").get("content").asText(); + } + } + return null; + } + + // Extract participants from the comma-separated text property + private List extractParticipants(JsonNode properties) { + String participantsString = extractTextFromProperty(properties, "참여 학생", "rich_text"); + if (participantsString != null && !participantsString.isEmpty()) { + return Arrays.asList(participantsString.split("\\s*,\\s*")); + } + return new ArrayList<>(); + } + + // Extract multi-select property + private List extractMultiSelect(JsonNode properties, String fieldName) { + List result = new ArrayList<>(); + if (properties.has(fieldName)) { + JsonNode multiSelectNode = properties.get(fieldName).get("multi_select"); + if (multiSelectNode.isArray()) { + multiSelectNode.forEach(node -> result.add(node.get("name").asText())); + } + } + return result; + } + + // Extract logo url from the files property + private String extractLogoUrl(JsonNode properties, String fieldName) { + if (properties.has(fieldName)) { + JsonNode filesNode = properties.get(fieldName).get("files"); + if (filesNode.isArray() && filesNode.size() > 0) { + JsonNode firstFileNode = filesNode.get(0); + + // Handle internal file type + if (firstFileNode.has("file")) { + JsonNode fileNode = firstFileNode.get("file"); + if (fileNode != null && fileNode.has("url")) { + return fileNode.get("url").asText(); + } + } + + // Handle external file type + if (firstFileNode.has("external")) { + JsonNode externalNode = firstFileNode.get("external"); + if (externalNode != null && externalNode.has("url")) { + return externalNode.get("url").asText(); + } + } + } + } + return null; + } + + // Extract url from website property + private String extractUrl(JsonNode properties, String fieldName) { + if (properties.has(fieldName)) { + JsonNode urlNode = properties.get(fieldName).get("url"); + if (urlNode != null && !urlNode.isNull()) { + return urlNode.asText(); + } + } + return null; + } + +} diff --git a/src/main/java/com/scg/stop/jobInfo/service/JobInfoService.java b/src/main/java/com/scg/stop/jobInfo/service/JobInfoService.java new file mode 100644 index 00000000..2579378a --- /dev/null +++ b/src/main/java/com/scg/stop/jobInfo/service/JobInfoService.java @@ -0,0 +1,137 @@ +package com.scg.stop.jobInfo.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.scg.stop.global.exception.BadRequestException; +import com.scg.stop.global.exception.ExceptionCode; +import com.scg.stop.jobInfo.dto.request.JobInfoRequest; +import com.scg.stop.jobInfo.dto.response.JobInfoResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestTemplate; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Transactional +public class JobInfoService { + + // Notion API values + @Value("${spring.notion.secretKey}") + private String secretKey; + + @Value("${spring.notion.version}") + private String version; + + @Value("${spring.notion.databaseId.jobInfo}") + private String jobInfoDatabaseId; + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + + // Constants for Notion DB property names + private static final String COMPANY_NAME_PROPERTY = "기업명"; + private static final String JOB_TYPES_PROPERTY = "고용 형태"; + private static final String REGION_PROPERTY = "근무 지역"; + private static final String POSITION_PROPERTY = "채용 포지션"; + private static final String STATE_PROPERTY = "채용 상태"; + private static final String HIRING_TIME_PROPERTY = "채용 시점"; + + /** + * Fetches job info from Notion database + * + * @param jobInfoRequest Request object containing search criteria + * @param pageable Pageable object containing page number and size + * @return Page of job info + */ + public Page getJobInfos(JobInfoRequest jobInfoRequest, Pageable pageable) { + return fetchNotionData(jobInfoDatabaseId, jobInfoRequest, pageable, JobInfoResponse.class); + } + + private Page fetchNotionData(String databaseId, Object requestObject, Pageable pageable, Class responseType) { + try { + String url = "https://api.notion.com/v1/databases/" + databaseId + "/query"; + + HttpHeaders headers = createHeaders(); + Map requestBody = createRequestBody(requestObject); + HttpEntity> requestEntity = new HttpEntity<>(requestBody, headers); + + ResponseEntity response = restTemplate.exchange(url, HttpMethod.POST, requestEntity, String.class); + JsonNode root = objectMapper.readTree(response.getBody()).get("results"); + List entities = objectMapper.convertValue(root, objectMapper.getTypeFactory().constructCollectionType(List.class, responseType)); + + int start = Math.min((int) pageable.getOffset(), entities.size()); + int end = Math.min(start + pageable.getPageSize(), entities.size()); + return new PageImpl<>(entities.subList(start, end), pageable, entities.size()); + + } catch (Exception e) { + throw new BadRequestException(ExceptionCode.FAILED_TO_FETCH_NOTION_DATA); + } + } + + // Helper methods for creating request header + private HttpHeaders createHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(secretKey); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Notion-Version", version); + return headers; + } + + // Helper method for creating request body + private Map createRequestBody(Object requestObject) { + Map requestBody = new HashMap<>(); + Map filter = new HashMap<>(); + List> andConditions = new ArrayList<>(); + + // Extract search criteria from request object + JobInfoRequest jobInfoRequest = (JobInfoRequest) requestObject; + addStringFilterCondition(jobInfoRequest.getCompany(), COMPANY_NAME_PROPERTY, "title", andConditions); + addMultiSelectFilterConditions(jobInfoRequest.getJobTypes(), JOB_TYPES_PROPERTY, andConditions); + addStringFilterCondition(jobInfoRequest.getRegion(), REGION_PROPERTY, "rich_text", andConditions); + addStringFilterCondition(jobInfoRequest.getPosition(), POSITION_PROPERTY, "rich_text", andConditions); + addMultiSelectFilterConditions(jobInfoRequest.getState(), STATE_PROPERTY, andConditions); + addStringFilterCondition(jobInfoRequest.getHiringTime(), HIRING_TIME_PROPERTY, "rich_text", andConditions); + + + // AND conditions to filter + if (!andConditions.isEmpty()) { + filter.put("and", andConditions); + requestBody.put("filter", filter); + } + + return requestBody; + } + + // Helper method for adding String filter condition + private void addStringFilterCondition(String value, String property, String filterType, List> conditions) { + if (value != null && !value.isEmpty()) { + Map condition = new HashMap<>(); + condition.put("property", property); + condition.put(filterType, Map.of("contains", value)); + conditions.add(condition); + } + } + + // Helper method for adding MultiSelect filter conditions + private void addMultiSelectFilterConditions(List criteria, String property, List> conditions) { + if (criteria != null && !criteria.isEmpty()) { + criteria.forEach(criterion -> { + Map filterCondition = new HashMap<>(); + filterCondition.put("property", property); + filterCondition.put("multi_select", Map.of("contains", criterion)); + conditions.add(filterCondition); + }); + } + } +} diff --git a/src/main/java/com/scg/stop/notice/controller/NoticeController.java b/src/main/java/com/scg/stop/notice/controller/NoticeController.java new file mode 100644 index 00000000..12bf6456 --- /dev/null +++ b/src/main/java/com/scg/stop/notice/controller/NoticeController.java @@ -0,0 +1,77 @@ +package com.scg.stop.notice.controller; + +import com.scg.stop.auth.annotation.AuthUser; +import com.scg.stop.notice.dto.request.NoticeRequest; +import com.scg.stop.notice.dto.response.NoticeListElementResponse; +import com.scg.stop.notice.dto.response.NoticeResponse; +import com.scg.stop.notice.service.NoticeService; +import com.scg.stop.user.domain.AccessType; +import com.scg.stop.user.domain.User; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RequestMapping("/notices") +@RestController +public class NoticeController { + + private final NoticeService noticeService; + + // Create a new notice + @PostMapping + public ResponseEntity createNotice( + @RequestBody @Valid NoticeRequest createNoticeDto, + @AuthUser(accessType = {AccessType.ADMIN}) User user) { + return ResponseEntity.status(HttpStatus.CREATED).body(noticeService.createNotice(createNoticeDto)); + } + + // Get a list of notices + @GetMapping + public ResponseEntity> getNoticeList( + @RequestParam(value = "terms", required = false) String searchTerm, + @RequestParam(value = "scope", defaultValue = "both") String searchScope, + @PageableDefault(page = 0, size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) { + + // Enforce that searchScope is ignored if searchTerm is null + if (searchTerm == null || searchTerm.isEmpty()) { + searchScope = null; // Ignore search scope + } + + Page noticeList = noticeService.getNoticeList(searchTerm, searchScope, pageable); + return ResponseEntity.status(HttpStatus.OK).body(noticeList); + } + + + // Get a corresponding notice + @GetMapping("/{noticeId}") + public ResponseEntity getNotice( + @PathVariable("noticeId") Long noticeId) { + NoticeResponse notice = noticeService.getNotice(noticeId); + return ResponseEntity.status(HttpStatus.OK).body(notice); + } + + // Update a corresponding notice + @PutMapping("/{noticeId}") + public ResponseEntity updateNotice( + @PathVariable("noticeId") Long noticeId, + @RequestBody @Valid NoticeRequest updateNoticeDto, + @AuthUser(accessType = {AccessType.ADMIN}) User user) { + return ResponseEntity.status(HttpStatus.OK).body(noticeService.updateNotice(noticeId, updateNoticeDto)); + } + + // Delete a corresponding notice + @DeleteMapping("/{noticeId}") + public ResponseEntity deleteNotice( + @PathVariable("noticeId") Long noticeId, + @AuthUser(accessType = {AccessType.ADMIN}) User user) { + noticeService.deleteNotice(noticeId); + return ResponseEntity.noContent().build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/scg/stop/notice/domain/Notice.java b/src/main/java/com/scg/stop/notice/domain/Notice.java new file mode 100644 index 00000000..d905bbd4 --- /dev/null +++ b/src/main/java/com/scg/stop/notice/domain/Notice.java @@ -0,0 +1,76 @@ +package com.scg.stop.notice.domain; + +import com.scg.stop.file.domain.File; +import com.scg.stop.global.domain.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +import static jakarta.persistence.CascadeType.ALL; +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +@Entity +@Getter +@NoArgsConstructor(access = PROTECTED) +public class Notice extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @Column(nullable = false) + private String title; + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @Column(nullable = false) + private Integer hitCount = 0; + + @Column(nullable = false, columnDefinition = "TINYINT(1)") + private boolean fixed; + + @OneToMany(fetch = LAZY, mappedBy = "notice", cascade = ALL, orphanRemoval = true) + private List files = new ArrayList<>(); + + // private constructor for creating new notice entity + private Notice(String title, String content, Integer hitCount, boolean fixed, List files) { + this.title = title; + this.content = content; + this.hitCount = hitCount; + this.fixed = fixed; + files.forEach(file -> file.setNotice(this)); + + } + + // static method for creating new notice entity + public static Notice from(String title, String content, boolean fixed, List files) { + return new Notice( + title, + content, + 0, + fixed, + files + ); + } + + public void updateNotice(String title, String content, boolean fixed, List files) { + this.title = title; + this.content = content; + this.fixed = fixed; + this.files.clear(); + if (files != null) { + this.files.addAll(files); + files.forEach(file -> file.setNotice(this)); + } + } + + public void increaseHitCount() { + this.hitCount++; + } +} diff --git a/src/main/java/com/scg/stop/notice/dto/request/NoticeRequest.java b/src/main/java/com/scg/stop/notice/dto/request/NoticeRequest.java new file mode 100644 index 00000000..a9214244 --- /dev/null +++ b/src/main/java/com/scg/stop/notice/dto/request/NoticeRequest.java @@ -0,0 +1,27 @@ +package com.scg.stop.notice.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class NoticeRequest { + + @NotBlank(message = "제목을 입력해주세요.") + private String title; + + @NotBlank(message = "내용을 입력해주세요.") + private String content; + + @NotNull(message = "고정 여부를 입력해주세요.") + private boolean fixed; + + private List fileIds; + +} \ No newline at end of file diff --git a/src/main/java/com/scg/stop/notice/dto/response/NoticeListElementResponse.java b/src/main/java/com/scg/stop/notice/dto/response/NoticeListElementResponse.java new file mode 100644 index 00000000..52125a95 --- /dev/null +++ b/src/main/java/com/scg/stop/notice/dto/response/NoticeListElementResponse.java @@ -0,0 +1,32 @@ +package com.scg.stop.notice.dto.response; + +import com.scg.stop.notice.domain.Notice; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class NoticeListElementResponse { + private Long id; + private String title; + private Integer hitCount; + private boolean fixed; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + // Entity -> DTO + public static NoticeListElementResponse from(Notice notice) { + return new NoticeListElementResponse( + notice.getId(), + notice.getTitle(), + notice.getHitCount(), + notice.isFixed(), + notice.getCreatedAt(), + notice.getUpdatedAt() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/scg/stop/notice/dto/response/NoticeResponse.java b/src/main/java/com/scg/stop/notice/dto/response/NoticeResponse.java new file mode 100644 index 00000000..8d111bbe --- /dev/null +++ b/src/main/java/com/scg/stop/notice/dto/response/NoticeResponse.java @@ -0,0 +1,45 @@ +package com.scg.stop.notice.dto.response; + +import com.scg.stop.file.domain.File; +import com.scg.stop.file.dto.response.FileResponse; +import com.scg.stop.notice.domain.Notice; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class NoticeResponse { + private Long id; + private String title; + private String content; + private Integer hitCount; + private boolean fixed; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private List files; + + // create a new NoticeResponse object + public static NoticeResponse from(Notice notice, List files) { + + List fileResponses = files.stream() + .map(FileResponse::from) + .collect(Collectors.toList()); + + return new NoticeResponse( + notice.getId(), + notice.getTitle(), + notice.getContent(), + notice.getHitCount(), + notice.isFixed(), + notice.getCreatedAt(), + notice.getUpdatedAt(), + fileResponses + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/scg/stop/notice/repository/NoticeRepository.java b/src/main/java/com/scg/stop/notice/repository/NoticeRepository.java new file mode 100644 index 00000000..70d7fc73 --- /dev/null +++ b/src/main/java/com/scg/stop/notice/repository/NoticeRepository.java @@ -0,0 +1,37 @@ +package com.scg.stop.notice.repository; + +import com.scg.stop.notice.domain.Notice; +import com.scg.stop.notice.dto.response.NoticeListElementResponse; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface NoticeRepository extends JpaRepository { + + @Query("SELECT n FROM Notice n " + + "WHERE (:searchScope IS NULL OR " + + "(:searchScope = 'content' AND (:searchTerm IS NULL OR n.content LIKE %:searchTerm%)) " + + "OR (:searchScope = 'title' AND (:searchTerm IS NULL OR n.title LIKE %:searchTerm%)) " + + "OR (:searchScope = 'both' AND (:searchTerm IS NULL OR n.title LIKE %:searchTerm% OR n.content LIKE %:searchTerm%))) " + + "AND n.fixed = false") + Page findNonFixedNotices( + @Param("searchTerm") String searchTerm, + @Param("searchScope") String searchScope, + Pageable pageable); + + @Query("SELECT n FROM Notice n " + + "WHERE (:searchScope IS NULL OR " + + "(:searchScope = 'content' AND (:searchTerm IS NULL OR n.content LIKE %:searchTerm%)) " + + "OR (:searchScope = 'title' AND (:searchTerm IS NULL OR n.title LIKE %:searchTerm%)) " + + "OR (:searchScope = 'both' AND (:searchTerm IS NULL OR n.title LIKE %:searchTerm% OR n.content LIKE %:searchTerm%))) " + + "AND n.fixed = true") + List findFixedNotices( + @Param("searchTerm") String searchTerm, + @Param("searchScope") String searchScope, + Sort sort); +} diff --git a/src/main/java/com/scg/stop/notice/service/NoticeService.java b/src/main/java/com/scg/stop/notice/service/NoticeService.java new file mode 100644 index 00000000..14e0b596 --- /dev/null +++ b/src/main/java/com/scg/stop/notice/service/NoticeService.java @@ -0,0 +1,154 @@ +package com.scg.stop.notice.service; + +import com.scg.stop.file.domain.File; +import com.scg.stop.file.repository.FileRepository; +import com.scg.stop.global.exception.BadRequestException; +import com.scg.stop.global.exception.ExceptionCode; +import com.scg.stop.notice.domain.Notice; +import com.scg.stop.notice.dto.request.NoticeRequest; +import com.scg.stop.notice.dto.response.NoticeListElementResponse; +import com.scg.stop.notice.dto.response.NoticeResponse; +import com.scg.stop.notice.repository.NoticeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +@Service +@Transactional +@RequiredArgsConstructor +public class NoticeService { + + private final NoticeRepository noticeRepository; + private final FileRepository fileRepository; + + /** + * Create a new notice + * + * @param request Notice Request DTO + * @return Notice Response DTO + */ + public NoticeResponse createNotice(NoticeRequest request) { + List attachedFiles = getAttachedFiles(request.getFileIds()); + Notice newNotice = Notice.from( + request.getTitle(), + request.getContent(), + request.isFixed(), + attachedFiles + ); + noticeRepository.save(newNotice); + return NoticeResponse.from(newNotice, attachedFiles); + } + + /** + * Get a list of notices with sorting + * + * @param searchTerm Search term to filter notices (optional) + * @param searchScope Search scope to filter notices (optional) + * @param pageable Pageable containing sorting information + * @return List of notices + */ + @Transactional(readOnly = true) + public Page getNoticeList(String searchTerm, String searchScope, Pageable pageable) { + + // If no searchTerm is provided, set searchScope to null + if (searchTerm == null || searchTerm.isEmpty()) { + searchScope = null; + } + + // Retrieve the sorting from the pageable + Sort sort = pageable.getSort(); + + // Find fixed notices based on the search criteria + List fixedNotices = noticeRepository.findFixedNotices(searchTerm, searchScope, sort); + + // Find non-fixed notices based on the search criteria + int nonFixedNoticesSize = pageable.getPageSize() - fixedNotices.size(); + Pageable adjustedPageable = PageRequest.of(pageable.getPageNumber(), Math.max(nonFixedNoticesSize, 0), sort); + Page nonFixedNotices = noticeRepository.findNonFixedNotices(searchTerm, searchScope, adjustedPageable); + + // Combine fixed and non-fixed notices + List combinedNotices = new ArrayList<>(); + combinedNotices.addAll(fixedNotices); + combinedNotices.addAll(nonFixedNotices.getContent()); + + // Calculate total elements and pages + long totalElements = fixedNotices.size() + nonFixedNotices.getTotalElements(); + int totalPages = (int) Math.ceil((double) nonFixedNotices.getTotalElements() / adjustedPageable.getPageSize()); + + // Return the combined result as a Page with sorting + return new PageImpl<>(combinedNotices, pageable, totalElements) { + @Override + public int getTotalPages() { + return totalPages; + } + + @Override + public long getTotalElements() { + return totalElements; + } + }; + } + + /** + * Get a corresponding notice + * + * @param noticeId ID of the notice + * @return Notice Response DTO + */ + public NoticeResponse getNotice(Long noticeId) { + Notice notice = noticeRepository.findById(noticeId).orElseThrow(() -> + new BadRequestException(ExceptionCode.NOTICE_NOT_FOUND)); + notice.increaseHitCount(); + return NoticeResponse.from(notice, notice.getFiles()); + } + + /** + * Update a corresponding notice + * + * @param noticeId ID of the notice + * @param request Notice Request DTO + * @return Notice Response DTO + */ + public NoticeResponse updateNotice(Long noticeId, NoticeRequest request) { + Notice notice = noticeRepository.findById(noticeId).orElseThrow(() -> + new BadRequestException(ExceptionCode.NOTICE_NOT_FOUND)); + List attachedFiles = getAttachedFiles(request.getFileIds()); + + notice.updateNotice(request.getTitle(), request.getContent(), request.isFixed(), attachedFiles); + noticeRepository.save(notice); + return NoticeResponse.from(notice, attachedFiles); + } + + /** + * Delete a corresponding notice + * + * @param noticeId ID of the notice + */ + public void deleteNotice(Long noticeId) { + Notice notice = noticeRepository.findById(noticeId).orElseThrow(() -> + new BadRequestException(ExceptionCode.NOTICE_NOT_FOUND)); + noticeRepository.delete(notice); + } + + /** + * Helper method to get attached files + * + * @param fileIds List of file IDs + * @return List of files + */ + private List getAttachedFiles(List fileIds) { + if (fileIds == null || fileIds.isEmpty()) { + return List.of(); + } + List attachedFiles = fileRepository.findByIdIn(fileIds); + if (attachedFiles.size() != fileIds.size()) { + throw new BadRequestException(ExceptionCode.FILE_NOT_FOUND); + } + return attachedFiles; + } + +} \ No newline at end of file diff --git a/src/main/java/com/scg/stop/project/controller/InquiryController.java b/src/main/java/com/scg/stop/project/controller/InquiryController.java new file mode 100644 index 00000000..b6fc6f6a --- /dev/null +++ b/src/main/java/com/scg/stop/project/controller/InquiryController.java @@ -0,0 +1,121 @@ +package com.scg.stop.project.controller; + +import com.scg.stop.auth.annotation.AuthUser; +import com.scg.stop.project.dto.request.InquiryReplyRequest; +import com.scg.stop.project.dto.request.InquiryRequest; +import com.scg.stop.project.dto.response.InquiryDetailResponse; +import com.scg.stop.project.dto.response.InquiryReplyResponse; +import com.scg.stop.project.dto.response.InquiryResponse; +import com.scg.stop.project.service.InquiryService; +import com.scg.stop.user.domain.AccessType; +import com.scg.stop.user.domain.User; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/inquiries") +public class InquiryController { + + private final InquiryService inquiryService; + + // 문의 목록 조회 + @GetMapping() + public ResponseEntity> getInquiries( + @AuthUser(accessType = {AccessType.COMPANY, AccessType.ADMIN}) User user, + @RequestParam(value = "terms", required = false) String searchTerm, + @RequestParam(value = "scope", defaultValue = "both") String searchScope, + @PageableDefault(page = 0, size = 10) Pageable pageable) { + + // Enforce that searchScope is ignored if searchTerm is null + if (searchTerm == null || searchTerm.isEmpty()) { + searchScope = null; // Ignore search scope + } + + Page inquiryList = inquiryService.getInquiryList(searchTerm, searchScope, pageable); + return ResponseEntity.status(HttpStatus.OK).body(inquiryList); + } + + // 문의 상세 조회 + @GetMapping("/{inquiryId}") + public ResponseEntity getInquiry( + @AuthUser(accessType = {AccessType.COMPANY, AccessType.ADMIN}) User user, + @PathVariable("inquiryId") Long inquiryId) { + + InquiryDetailResponse inquiryDetailResponse = inquiryService.getInquiry(inquiryId, user); + return ResponseEntity.status(HttpStatus.OK).body(inquiryDetailResponse); + + } + + // 문의 수정 + @PutMapping("/{inquiryId}") + public ResponseEntity updateInquiry( + @AuthUser(accessType = {AccessType.COMPANY, AccessType.ADMIN}) User user, + @PathVariable("inquiryId") Long inquiryId, + @RequestBody @Valid InquiryRequest inquiryUpdateRequest) { + + InquiryDetailResponse inquiryDetailResponse = inquiryService.updateInquiry(inquiryId, user, + inquiryUpdateRequest); + return ResponseEntity.status(HttpStatus.OK).body(inquiryDetailResponse); + } + + // 문의 삭제 + @DeleteMapping("/{inquiryId}") + public ResponseEntity deleteInquiry( + @AuthUser(accessType = {AccessType.ADMIN, AccessType.COMPANY}) User user, + @PathVariable("inquiryId") Long inquiryId) { + + inquiryService.deleteInquiry(inquiryId, user); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + // 문의 답변 등록 + @PostMapping("/{inquiryId}/reply") + public ResponseEntity createInquiryReply( + @AuthUser(accessType = {AccessType.ADMIN}) User user, + @PathVariable("inquiryId") Long inquiryId, + @RequestBody @Valid InquiryReplyRequest inquiryReplyRequest) { + + InquiryReplyResponse inquiryReplyResponse = inquiryService.createInquiryReply(inquiryId, inquiryReplyRequest); + return ResponseEntity.status(HttpStatus.CREATED).body(inquiryReplyResponse); + } + + // 문의 답변 조회 + @GetMapping("/{inquiryId}/reply") + public ResponseEntity getInquiryReply( + @AuthUser(accessType = {AccessType.COMPANY, AccessType.ADMIN}) User user, + @PathVariable("inquiryId") Long inquiryId) { + + InquiryReplyResponse inquiryReplyResponse = inquiryService.getInquiryReply(inquiryId, user); + return ResponseEntity.status(HttpStatus.OK).body(inquiryReplyResponse); + } + + // 문의 답변 수정 + @PutMapping("/{inquiryId}/reply") + public ResponseEntity updateInquiryReply( + @AuthUser(accessType = {AccessType.ADMIN}) User user, + @PathVariable("inquiryId") Long inquiryId, + @RequestBody @Valid InquiryReplyRequest inquiryReplyRequest) { + + InquiryReplyResponse inquiryReplyResponse = inquiryService.updateInquiryReply(inquiryId, inquiryReplyRequest); + return ResponseEntity.status(HttpStatus.OK).body(inquiryReplyResponse); + } + + // 문의 답변 삭제 + @DeleteMapping("/{inquiryId}/reply") + public ResponseEntity deleteInquiryReply( + @AuthUser(accessType = {AccessType.ADMIN}) User user, + @PathVariable("inquiryId") Long inquiryId) { + + inquiryService.deleteInquiryReply(inquiryId); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + +} diff --git a/src/main/java/com/scg/stop/project/controller/ProjectController.java b/src/main/java/com/scg/stop/project/controller/ProjectController.java new file mode 100644 index 00000000..e26939d1 --- /dev/null +++ b/src/main/java/com/scg/stop/project/controller/ProjectController.java @@ -0,0 +1,161 @@ +package com.scg.stop.project.controller; + +import com.scg.stop.auth.annotation.AuthUser; +import com.scg.stop.project.domain.ProjectCategory; +import com.scg.stop.project.domain.ProjectType; +import com.scg.stop.project.dto.request.CommentRequest; +import com.scg.stop.project.dto.request.ProjectRequest; +import com.scg.stop.project.dto.response.CommentResponse; +import com.scg.stop.project.dto.response.ProjectDetailResponse; +import com.scg.stop.project.dto.response.ProjectResponse; +import com.scg.stop.project.dto.request.InquiryRequest; +import com.scg.stop.project.dto.response.InquiryDetailResponse; +import com.scg.stop.project.service.ProjectService; +import com.scg.stop.user.domain.AccessType; +import com.scg.stop.user.domain.User; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/projects") +public class ProjectController { + + private final ProjectService projectService; + + @GetMapping + public ResponseEntity> getProjects( + @RequestParam(value = "title", required = false) String title, + @RequestParam(value = "year", required = false) List year, + @RequestParam(value = "category", required = false) List category, + @RequestParam(value = "type", required = false) List type, + @PageableDefault(page = 0, size = 10) Pageable pageable, + @AuthUser(accessType = {AccessType.OPTIONAL}) User user + ){ + Page pageProjectResponse = projectService.getProjects(title, year, category, type, pageable, user); + return ResponseEntity.status(HttpStatus.OK).body(pageProjectResponse); + } + + @PostMapping + public ResponseEntity createProject( + @RequestBody @Valid ProjectRequest projectRequest, + @AuthUser(accessType = {AccessType.ADMIN}) User user + ) { + ProjectDetailResponse projectDetailResponse = projectService.createProject(projectRequest, user); + return ResponseEntity.status(HttpStatus.CREATED).body(projectDetailResponse); + } + + @GetMapping("/{projectId}") + public ResponseEntity getProject( + @PathVariable("projectId") Long projectId, + @AuthUser(accessType = {AccessType.OPTIONAL}) User user + ) { + ProjectDetailResponse projectDetailResponse = projectService.getProject(projectId, user); + return ResponseEntity.status(HttpStatus.OK).body(projectDetailResponse); + } + + @PutMapping("/{projectId}") + public ResponseEntity updateProject( + @PathVariable("projectId") Long projectId, + @RequestBody @Valid ProjectRequest projectRequest, + @AuthUser(accessType = {AccessType.ADMIN}) User user + ) { + ProjectDetailResponse projectDetailResponse = projectService.updateProject(projectId, projectRequest, user); + return ResponseEntity.status(HttpStatus.OK).body(projectDetailResponse); + } + + @DeleteMapping("/{projectId}") + public ResponseEntity deleteProject( + @PathVariable("projectId") Long projectId, + @AuthUser(accessType = {AccessType.ADMIN}) User user + ) { + projectService.deleteProject(projectId); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/{projectId}/favorite") + public ResponseEntity createProjectFavorite( + @PathVariable("projectId") Long projectId, + @AuthUser(accessType = {AccessType.ALL}) User user + ){ + projectService.createProjectFavorite(projectId, user); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @DeleteMapping("/{projectId}/favorite") + public ResponseEntity deleteProjectFavorite( + @PathVariable("projectId") Long projectId, + @AuthUser(accessType = {AccessType.ALL}) User user + ){ + projectService.deleteProjectFavorite(projectId, user); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/{projectId}/like") + public ResponseEntity createProjectLike( + @PathVariable("projectId") Long projectId, + @AuthUser(accessType = {AccessType.ALL}) User user + ){ + projectService.createProjectLike(projectId, user); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @DeleteMapping("/{projectId}/like") + public ResponseEntity deleteProjectLike( + @PathVariable("projectId") Long projectId, + @AuthUser(accessType = {AccessType.ALL}) User user + ){ + projectService.deleteProjectLike(projectId, user); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/{projectId}/comment") + public ResponseEntity createProjectComment( + @PathVariable("projectId") Long projectId, + @RequestBody @Valid CommentRequest commentRequest, + @AuthUser(accessType = {AccessType.ALL}) User user + ){ + CommentResponse commentResponse = projectService.createProjectComment(projectId, user, commentRequest); + return ResponseEntity.status(HttpStatus.CREATED).body(commentResponse); + } + + @DeleteMapping("/{projectId}/comment/{commentId}") + public ResponseEntity deleteProjectComment( + @PathVariable("projectId") Long projectId, + @PathVariable("commentId") Long commentId, + @AuthUser(accessType = {AccessType.ALL}) User user + ){ + projectService.deleteProjectComment(projectId, commentId, user); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/award") + public ResponseEntity> getAwardProjects( + @RequestParam(value = "year", required = true) Integer year, + @PageableDefault(page = 0, size = 10) Pageable pageable, + @AuthUser(accessType = {AccessType.OPTIONAL}) User user + ){ + Page pageProjectResponse = projectService.getAwardProjects(year, pageable, user); + return ResponseEntity.status(HttpStatus.OK).body(pageProjectResponse); + } + + // 문의 생성 + @PostMapping("/{projectId}/inquiry") + public ResponseEntity createProjectInquiry( + @PathVariable Long projectId, + @AuthUser(accessType = {AccessType.COMPANY, AccessType.ADMIN}) User user, + @RequestBody @Valid InquiryRequest inquiryRequest) { + + InquiryDetailResponse inquiryDetailResponse = projectService.createProjectInquiry(projectId, user, inquiryRequest); + return ResponseEntity.status(HttpStatus.CREATED).body(inquiryDetailResponse); + + } +} diff --git a/src/main/java/com/scg/stop/project/controller/ProjectExcelController.java b/src/main/java/com/scg/stop/project/controller/ProjectExcelController.java new file mode 100644 index 00000000..99b2bdbf --- /dev/null +++ b/src/main/java/com/scg/stop/project/controller/ProjectExcelController.java @@ -0,0 +1,37 @@ +package com.scg.stop.project.controller; + +import com.scg.stop.auth.annotation.AuthUser; +import com.scg.stop.project.dto.response.FileResponse; +import com.scg.stop.project.dto.response.ProjectExcelResponse; +import com.scg.stop.project.service.ProjectExcelService; +import com.scg.stop.user.domain.AccessType; +import com.scg.stop.user.domain.User; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +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.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/projects/excel") +public class ProjectExcelController { + + private final ProjectExcelService projectExcelService; + + @PostMapping + public ResponseEntity createProjectExcel( + @RequestPart("excel") MultipartFile excelFile, + @RequestPart("thumbnails") List thumbnails, + @RequestPart("posters") List posters, + @AuthUser(accessType = {AccessType.ADMIN}) User user + ) { + ProjectExcelResponse projectExcelResponse = projectExcelService.createProjectExcel(excelFile, thumbnails, posters); + return ResponseEntity.status(HttpStatus.CREATED).body(projectExcelResponse); + } +} diff --git a/src/main/java/com/scg/stop/project/domain/AwardStatus.java b/src/main/java/com/scg/stop/project/domain/AwardStatus.java new file mode 100644 index 00000000..c6c3ef93 --- /dev/null +++ b/src/main/java/com/scg/stop/project/domain/AwardStatus.java @@ -0,0 +1,38 @@ +package com.scg.stop.project.domain; + +import com.scg.stop.global.exception.BadRequestException; + +import java.util.HashMap; +import java.util.Map; + +import static com.scg.stop.global.exception.ExceptionCode.INVALID_AWARD_STATUS_KOREAN_NAME; + +public enum AwardStatus { + + NONE("없음"), + FIRST("대상"), + SECOND("최우수상"), + THIRD("우수상"), + FOURTH("장려상"), + FIFTH("인기상"); + + private static final Map KOREAN_NAME_MAP = new HashMap<>(); + private final String koreanName; + + static { + for (AwardStatus awardStatus : AwardStatus.values()) { + KOREAN_NAME_MAP.put(awardStatus.koreanName, awardStatus); + } + } + + AwardStatus(String koreanName) { + this.koreanName = koreanName; + } + + public static AwardStatus fromKoreanName(String koreanName) { + if (!KOREAN_NAME_MAP.containsKey(koreanName)) { + throw new BadRequestException(INVALID_AWARD_STATUS_KOREAN_NAME, String.format("수상 내역의 한글 이름이 올바르지 않습니다 : %s", koreanName)); + } + return KOREAN_NAME_MAP.get(koreanName); + } +} diff --git a/src/main/java/com/scg/stop/project/domain/Comment.java b/src/main/java/com/scg/stop/project/domain/Comment.java new file mode 100644 index 00000000..a4ae3553 --- /dev/null +++ b/src/main/java/com/scg/stop/project/domain/Comment.java @@ -0,0 +1,46 @@ +package com.scg.stop.project.domain; + +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import com.scg.stop.user.domain.User; +import com.scg.stop.global.domain.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = PROTECTED) +public class Comment extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @Column(nullable = false, columnDefinition = "TINYINT(1)") + private Boolean isAnonymous; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "project_id") + private Project project; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "user_id") + private User user; + + public void setUser(User user) { + this.user = user; + } +} \ No newline at end of file diff --git a/src/main/java/com/scg/stop/project/domain/FavoriteProject.java b/src/main/java/com/scg/stop/project/domain/FavoriteProject.java new file mode 100644 index 00000000..8c8e7977 --- /dev/null +++ b/src/main/java/com/scg/stop/project/domain/FavoriteProject.java @@ -0,0 +1,37 @@ +package com.scg.stop.project.domain; + +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import com.scg.stop.user.domain.User; +import com.scg.stop.global.domain.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = PROTECTED) +@Table( + name = "favorite_projects", + uniqueConstraints = { + @UniqueConstraint(columnNames = {"project_id", "user_id"}) + } +) +public class FavoriteProject extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "project_id") + private Project project; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "user_id") + private User user; +} diff --git a/src/main/java/com/scg/stop/project/domain/Inquiry.java b/src/main/java/com/scg/stop/project/domain/Inquiry.java new file mode 100644 index 00000000..524bb986 --- /dev/null +++ b/src/main/java/com/scg/stop/project/domain/Inquiry.java @@ -0,0 +1,62 @@ +package com.scg.stop.project.domain; + +import com.scg.stop.global.domain.BaseTimeEntity; +import com.scg.stop.user.domain.User; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +@Entity +@Getter +@NoArgsConstructor(access = PROTECTED) +public class Inquiry extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @Column(nullable = false) + private String title; + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "project_id") + private Project project; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "user_id") + private User user; + + @OneToOne(fetch = LAZY, mappedBy = "inquiry") + private InquiryReply reply; + + private Inquiry(String title, String content, Project project, User user) { + this.title = title; + this.content = content; + this.project = project; + this.user = user; + } + + public static Inquiry createInquiry(String title, String content, Project project, User user) { + return new Inquiry(title, content, project, user); + } + + public void updateInquiry(String title, String content) { + this.title = title; + this.content = content; + } + + public void deleteReply() { + this.reply = null; + } + + public void setUser(User user) { + this.user = user; + } +} diff --git a/src/main/java/com/scg/stop/project/domain/InquiryReply.java b/src/main/java/com/scg/stop/project/domain/InquiryReply.java new file mode 100644 index 00000000..1015e475 --- /dev/null +++ b/src/main/java/com/scg/stop/project/domain/InquiryReply.java @@ -0,0 +1,45 @@ +package com.scg.stop.project.domain; + +import com.scg.stop.global.domain.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +@Entity +@Getter +@NoArgsConstructor(access = PROTECTED) +public class InquiryReply extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @Column(nullable = false) + private String title; + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @OneToOne(fetch = LAZY) + @JoinColumn(name = "inquiry_id") + private Inquiry inquiry; + + private InquiryReply(String title, String content, Inquiry inquiry) { + this.title = title; + this.content = content; + this.inquiry = inquiry; + } + + public static InquiryReply createInquiryReply(String title, String content, Inquiry inquiry) { + return new InquiryReply(title, content, inquiry); + } + + public void updateInquiryResponse(String title, String content) { + this.title = title; + this.content = content; + } +} diff --git a/src/main/java/com/scg/stop/project/domain/Likes.java b/src/main/java/com/scg/stop/project/domain/Likes.java new file mode 100644 index 00000000..74c55f39 --- /dev/null +++ b/src/main/java/com/scg/stop/project/domain/Likes.java @@ -0,0 +1,41 @@ +package com.scg.stop.project.domain; + +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import com.scg.stop.user.domain.User; +import com.scg.stop.global.domain.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = PROTECTED) +@Table( + name = "likes", + uniqueConstraints = { + @UniqueConstraint(columnNames = {"project_id", "user_id"}) + } +) +public class Likes extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "project_id") + private Project project; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "user_id") + private User user; + + public void setUser(User user) { + this.user = user; + } +} diff --git a/src/main/java/com/scg/stop/project/domain/Member.java b/src/main/java/com/scg/stop/project/domain/Member.java new file mode 100644 index 00000000..b24fc563 --- /dev/null +++ b/src/main/java/com/scg/stop/project/domain/Member.java @@ -0,0 +1,40 @@ +package com.scg.stop.project.domain; + +import static jakarta.persistence.EnumType.STRING; +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import com.scg.stop.global.domain.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = PROTECTED) +@AllArgsConstructor +public class Member extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + @Enumerated(value = STRING) + private Role role; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "project_id") + private Project project; +} \ No newline at end of file diff --git a/src/main/java/com/scg/stop/project/domain/Project.java b/src/main/java/com/scg/stop/project/domain/Project.java new file mode 100644 index 00000000..1348b7c0 --- /dev/null +++ b/src/main/java/com/scg/stop/project/domain/Project.java @@ -0,0 +1,111 @@ +package com.scg.stop.project.domain; + +import static jakarta.persistence.EnumType.STRING; +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import com.scg.stop.file.domain.File; +import com.scg.stop.global.domain.BaseTimeEntity; +import jakarta.persistence.*; + +import java.util.ArrayList; +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = PROTECTED) +@AllArgsConstructor +public class Project extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + @Enumerated(value = STRING) + private ProjectType type; + + @Column(nullable = false) + @Enumerated(value = STRING) + private ProjectCategory category; + + @Column(nullable = false) + private String team; + + @Column(nullable = false) + private String youtubeId; + + @Column(nullable = false) + private Integer year; + + private String url; + + @Column(nullable = false, columnDefinition = "TEXT") + private String description; + + @Column(nullable = false) + @Enumerated(value = STRING) + private AwardStatus awardStatus = AwardStatus.NONE; + + @OneToOne(fetch = LAZY, orphanRemoval = true) + @JoinColumn(name = "thumbnail_id") + private File thumbnail; + + @OneToOne(fetch = LAZY, orphanRemoval = true) + @JoinColumn(name = "poster_id") + private File poster; + + @OneToMany(fetch = LAZY, mappedBy = "project", cascade = CascadeType.ALL) + private List members = new ArrayList<>(); + + @OneToMany(fetch = LAZY, mappedBy = "project", cascade = CascadeType.REMOVE) + private List likes = new ArrayList<>(); + + @OneToMany(fetch = LAZY, mappedBy = "project", cascade = CascadeType.REMOVE) + private List favorites = new ArrayList<>(); + + @OneToMany(fetch = LAZY, mappedBy = "project", cascade = CascadeType.REMOVE) + private List comments = new ArrayList<>(); + + @OneToMany(fetch = LAZY, mappedBy = "project", cascade = CascadeType.REMOVE) + private List inquiries = new ArrayList<>(); + + public void update(Project project) { + this.name = project.getName(); + this.type = project.getType(); + this.category = project.getCategory(); + this.team = project.getTeam(); + this.youtubeId = project.getYoutubeId(); + this.year = project.getYear(); + this.url = project.getUrl(); + this.description = project.getDescription(); + this.awardStatus = project.getAwardStatus(); + this.thumbnail = project.getThumbnail(); + this.poster = project.getPoster(); + this.members = project.getMembers(); + } + + public void addLikes(Likes likes) { + this.likes.add(likes); + } + + public void removeLikes(Likes likes) { + this.likes.remove(likes); + } + + public void addFavoriteProject(FavoriteProject favoriteProject) { + this.favorites.add(favoriteProject); + } + + public void removeFavoriteProject(FavoriteProject favoriteProject) { + this.favorites.remove(favoriteProject); + } +} diff --git a/src/main/java/com/scg/stop/project/domain/ProjectCategory.java b/src/main/java/com/scg/stop/project/domain/ProjectCategory.java new file mode 100644 index 00000000..f375d5b0 --- /dev/null +++ b/src/main/java/com/scg/stop/project/domain/ProjectCategory.java @@ -0,0 +1,40 @@ +package com.scg.stop.project.domain; + +import com.scg.stop.global.exception.BadRequestException; + +import java.util.HashMap; +import java.util.Map; + +import static com.scg.stop.global.exception.ExceptionCode.INVALID_PROJECT_CATEGORY_KOREAN_NAME; + +public enum ProjectCategory { + + COMPUTER_VISION("컴퓨터비전"), + SYSTEM_NETWORK("시스템/네트워크"), + WEB_APPLICATION("웹/어플리케이션"), + SECURITY_SOFTWARE_ENGINEERING("보안/SW공학"), + NATURAL_LANGUAGE_PROCESSING("자연어처리"), + BIG_DATA_ANALYSIS("빅데이터분석"), + AI_MACHINE_LEARNING("AI/머신러닝"), + INTERACTION_AUGMENTED_REALITY("인터렉션/증강현실"); + + private static final Map KOREAN_NAME_MAP = new HashMap<>(); + private final String koreanName; + + static { + for (ProjectCategory projectCategory : ProjectCategory.values()) { + KOREAN_NAME_MAP.put(projectCategory.koreanName, projectCategory); + } + } + + ProjectCategory(String koreanName) { + this.koreanName = koreanName; + } + + public static ProjectCategory fromKoreanName(String koreanName) { + if (!KOREAN_NAME_MAP.containsKey(koreanName)) { + throw new BadRequestException(INVALID_PROJECT_CATEGORY_KOREAN_NAME, String.format("프로젝트 분야의 한글 이름이 올바르지 않습니다 : %s", koreanName)); + } + return KOREAN_NAME_MAP.get(koreanName); + } +} diff --git a/src/main/java/com/scg/stop/project/domain/ProjectType.java b/src/main/java/com/scg/stop/project/domain/ProjectType.java new file mode 100644 index 00000000..437649c4 --- /dev/null +++ b/src/main/java/com/scg/stop/project/domain/ProjectType.java @@ -0,0 +1,36 @@ +package com.scg.stop.project.domain; + +import com.scg.stop.global.exception.BadRequestException; + +import java.util.HashMap; +import java.util.Map; + +import static com.scg.stop.global.exception.ExceptionCode.INVALID_PROJECT_TYPE_KOREAN_NAME; + +public enum ProjectType { + + RESEARCH_AND_BUSINESS_FOUNDATION("산학"), + LAB("연구실"), + STARTUP("창업/SPARK"), + CLUB("동아리"); + + private static final Map KOREAN_NAME_MAP = new HashMap<>(); + private final String koreanName; + + static { + for (ProjectType projectType : ProjectType.values()) { + KOREAN_NAME_MAP.put(projectType.koreanName, projectType); + } + } + + ProjectType(String koreanName) { + this.koreanName = koreanName; + } + + public static ProjectType fromKoreanName(String koreanName) { + if (!KOREAN_NAME_MAP.containsKey(koreanName)) { + throw new BadRequestException(INVALID_PROJECT_TYPE_KOREAN_NAME, String.format("프로젝트 종류의 한글 이름이 올바르지 않습니다 : %s", koreanName)); + } + return KOREAN_NAME_MAP.get(koreanName); + } +} diff --git a/src/main/java/com/scg/stop/project/domain/Role.java b/src/main/java/com/scg/stop/project/domain/Role.java new file mode 100644 index 00000000..f69f7b1a --- /dev/null +++ b/src/main/java/com/scg/stop/project/domain/Role.java @@ -0,0 +1,7 @@ +package com.scg.stop.project.domain; + +public enum Role { + + STUDENT, + PROFESSOR +} diff --git a/src/main/java/com/scg/stop/project/dto/request/CommentRequest.java b/src/main/java/com/scg/stop/project/dto/request/CommentRequest.java new file mode 100644 index 00000000..6f25d09e --- /dev/null +++ b/src/main/java/com/scg/stop/project/dto/request/CommentRequest.java @@ -0,0 +1,29 @@ +package com.scg.stop.project.dto.request; + +import com.scg.stop.project.domain.Comment; +import com.scg.stop.project.domain.Project; +import com.scg.stop.user.domain.User; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class CommentRequest { + @NotBlank(message = "내용을 입력해주세요") + private String content; + + @NotNull(message = "익명 여부를 입력해주세요") + private Boolean isAnonymous; + + public Comment toEntity(Project project, User user) { + return new Comment( + null, + content, + isAnonymous, + project, + user + ); + } +} diff --git a/src/main/java/com/scg/stop/project/dto/request/InquiryReplyRequest.java b/src/main/java/com/scg/stop/project/dto/request/InquiryReplyRequest.java new file mode 100644 index 00000000..05482010 --- /dev/null +++ b/src/main/java/com/scg/stop/project/dto/request/InquiryReplyRequest.java @@ -0,0 +1,19 @@ +package com.scg.stop.project.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class InquiryReplyRequest { + + + @NotBlank(message = "문의 답변 제목을 입력해주세요.") + private String title; + + @NotBlank(message = "문의 답변 내용을 입력해주세요.") + private String content; +} diff --git a/src/main/java/com/scg/stop/project/dto/request/InquiryRequest.java b/src/main/java/com/scg/stop/project/dto/request/InquiryRequest.java new file mode 100644 index 00000000..f562b6f3 --- /dev/null +++ b/src/main/java/com/scg/stop/project/dto/request/InquiryRequest.java @@ -0,0 +1,17 @@ +package com.scg.stop.project.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class InquiryRequest { + + @NotBlank(message = "문의 제목을 입력해주세요.") + private String title; + + @NotBlank(message = "문의 내용을 입력해주세요.") + private String content; + +} diff --git a/src/main/java/com/scg/stop/project/dto/request/MemberRequest.java b/src/main/java/com/scg/stop/project/dto/request/MemberRequest.java new file mode 100644 index 00000000..d9101aab --- /dev/null +++ b/src/main/java/com/scg/stop/project/dto/request/MemberRequest.java @@ -0,0 +1,33 @@ +package com.scg.stop.project.dto.request; + +import static lombok.AccessLevel.PROTECTED; + +import com.scg.stop.project.domain.Member; +import com.scg.stop.project.domain.Project; +import com.scg.stop.project.domain.Role; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = PROTECTED) +public class MemberRequest { + + @NotBlank(message = "멤버 이름을 입력해주세요") + private String name; + + @NotNull(message = "멤버 역할을 입력해주세요") + private Role role; + + public Member toEntity(Project project) { + return new Member( + null, + name, + role, + project + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/scg/stop/project/dto/request/ProjectRequest.java b/src/main/java/com/scg/stop/project/dto/request/ProjectRequest.java new file mode 100644 index 00000000..385fb30d --- /dev/null +++ b/src/main/java/com/scg/stop/project/dto/request/ProjectRequest.java @@ -0,0 +1,110 @@ +package com.scg.stop.project.dto.request; + +import com.scg.stop.file.domain.File; +import com.scg.stop.project.domain.*; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +import java.util.ArrayList; +import java.util.List; + +@Getter +public class ProjectRequest { + + @NotNull(message = "썸네일 ID를 입력해주세요") + private final Long thumbnailId; + + @NotNull(message = "포스터 ID를 입력해주세요") + private final Long posterId; + + @NotBlank(message = "프로젝트 이름을 입력해주세요") + private final String projectName; + + @NotNull(message = "프로젝트 타입을 입력해주세요") + private final ProjectType projectType; + + @NotNull(message = "프로젝트 카테고리를 입력해주세요") + private final ProjectCategory projectCategory; + + @NotBlank(message = "팀 이름을 입력해주세요") + private final String teamName; + + @NotBlank(message = "프로젝트 yotubeId를 입력해주세요") + private final String youtubeId; + + @NotNull(message = "프로젝트 년도를 입력해주세요") + private final Integer year; + + @NotNull(message = "수상 여부를 입력해주세요") + private final AwardStatus awardStatus; + + @Valid + @NotNull(message = "멤버를 입력해주세요") + private final List members; + + private final String url; + + @NotBlank(message = "프로젝트 설명을 입력해주세요") + private final String description; + + public ProjectRequest( + Long thumbnailId, + Long posterId, + String projectName, + ProjectType projectType, + ProjectCategory projectCategory, + String teamName, + String youtubeId, + Integer year, + AwardStatus awardStatus, + List members, + String url, + String description + ) { + + this.thumbnailId = thumbnailId; + this.posterId = posterId; + this.projectName = projectName; + this.projectType = projectType; + this.projectCategory = projectCategory; + this.teamName = teamName; + this.youtubeId = youtubeId; + this.year = year; + this.awardStatus = awardStatus; + this.members = members; + this.url = url; + this.description = description; + } + + public Project toEntity(Long id, File thumbnail, File poster) { + Project project = new Project( + id, + projectName, + projectType, + projectCategory, + teamName, + youtubeId, + year, + url, + description, + awardStatus, + thumbnail, + poster, + new ArrayList<>(), + new ArrayList<>(), + new ArrayList<>(), + new ArrayList<>(), + new ArrayList<>() + ); + + List memberEntities = members.stream() + .map(memberRequest -> memberRequest.toEntity(project)) + .toList(); + + project.getMembers().addAll(memberEntities); + + return project; + } +} diff --git a/src/main/java/com/scg/stop/project/dto/response/CommentResponse.java b/src/main/java/com/scg/stop/project/dto/response/CommentResponse.java new file mode 100644 index 00000000..9f85ac93 --- /dev/null +++ b/src/main/java/com/scg/stop/project/dto/response/CommentResponse.java @@ -0,0 +1,31 @@ +package com.scg.stop.project.dto.response; + +import com.scg.stop.project.domain.Comment; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@AllArgsConstructor +public class CommentResponse { + private Long id; + private Long projectId; + private String userName; + private Boolean isAnonymous; + private String content; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public static CommentResponse of(Comment comment){ + return new CommentResponse( + comment.getId(), + comment.getProject().getId(), + comment.getIsAnonymous() ? null : comment.getUser().getName(), // 익명이 아닐 경우에만 사용자 이름을 반환 + comment.getIsAnonymous(), + comment.getContent(), + comment.getCreatedAt(), + comment.getUpdatedAt() + ); + } +} diff --git a/src/main/java/com/scg/stop/project/dto/response/FileResponse.java b/src/main/java/com/scg/stop/project/dto/response/FileResponse.java new file mode 100644 index 00000000..347fc093 --- /dev/null +++ b/src/main/java/com/scg/stop/project/dto/response/FileResponse.java @@ -0,0 +1,23 @@ +package com.scg.stop.project.dto.response; + +import com.scg.stop.file.domain.File; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class FileResponse { + private Long id; + private String uuid; + private String name; + private String mimeType; + + public static FileResponse from(File file) { + return new FileResponse( + file.getId(), + file.getUuid(), + file.getName(), + file.getMimeType() + ); + } +} diff --git a/src/main/java/com/scg/stop/project/dto/response/InquiryDetailResponse.java b/src/main/java/com/scg/stop/project/dto/response/InquiryDetailResponse.java new file mode 100644 index 00000000..fdd57d50 --- /dev/null +++ b/src/main/java/com/scg/stop/project/dto/response/InquiryDetailResponse.java @@ -0,0 +1,27 @@ +package com.scg.stop.project.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@NoArgsConstructor +@Getter +@AllArgsConstructor +public class InquiryDetailResponse { + + private Long id; + private String authorName; // 문의 작성자 이름 + private Long projectId; // 문의 대상 프로젝트 ID + private String projectName; // 문의 대상 프로젝트 이름 + private String title; + private String content; + private Boolean replied; // 답변 등록 여부 + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public static InquiryDetailResponse of(Long id, String authorName, Long projectId, String projectName, String title, String content, Boolean replied, LocalDateTime createdAt, LocalDateTime updatedAt) { + return new InquiryDetailResponse(id, authorName, projectId, projectName, title, content, replied, createdAt, updatedAt); + } +} diff --git a/src/main/java/com/scg/stop/project/dto/response/InquiryReplyResponse.java b/src/main/java/com/scg/stop/project/dto/response/InquiryReplyResponse.java new file mode 100644 index 00000000..e8c782dc --- /dev/null +++ b/src/main/java/com/scg/stop/project/dto/response/InquiryReplyResponse.java @@ -0,0 +1,20 @@ +package com.scg.stop.project.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class InquiryReplyResponse { + + private Long id; + private String title; + private String content; + + public static InquiryReplyResponse of(Long id, String title, String content) { + return new InquiryReplyResponse(id, title, content); + } + +} diff --git a/src/main/java/com/scg/stop/project/dto/response/InquiryResponse.java b/src/main/java/com/scg/stop/project/dto/response/InquiryResponse.java new file mode 100644 index 00000000..7c2593e3 --- /dev/null +++ b/src/main/java/com/scg/stop/project/dto/response/InquiryResponse.java @@ -0,0 +1,24 @@ +package com.scg.stop.project.dto.response; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class InquiryResponse { + + private Long id; + private String authorName; + private String title; + private LocalDateTime createdAt; + + public static InquiryResponse of(Long id, String name, String title, LocalDateTime createdAt) { + return new InquiryResponse(id, name, title, createdAt); + } + +} diff --git a/src/main/java/com/scg/stop/project/dto/response/ProjectDetailResponse.java b/src/main/java/com/scg/stop/project/dto/response/ProjectDetailResponse.java new file mode 100644 index 00000000..10c3195b --- /dev/null +++ b/src/main/java/com/scg/stop/project/dto/response/ProjectDetailResponse.java @@ -0,0 +1,73 @@ +package com.scg.stop.project.dto.response; + +import com.scg.stop.project.domain.*; +import com.scg.stop.user.domain.User; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.List; +import java.util.stream.Collectors; + +@Getter +@AllArgsConstructor +public class ProjectDetailResponse { + + private Long id; + private FileResponse thumbnailInfo; + private FileResponse posterInfo; + private String projectName; + private ProjectType projectType; + private ProjectCategory projectCategory; + private String teamName; + private String youtubeId; + private Integer year; + private AwardStatus awardStatus; + private List studentNames; + private List professorNames; + private Integer likeCount; + private Boolean like; + private Boolean bookMark; + private List comments; + private String url; + private String description; + + public static ProjectDetailResponse of(User user, Project project) { + List studentNames = project.getMembers().stream() + .filter(member -> member.getRole() == Role.STUDENT) + .map(Member::getName) + .collect(Collectors.toList()); + + List professorNames = project.getMembers().stream() + .filter(member -> member.getRole() == Role.PROFESSOR) + .map(Member::getName) + .collect(Collectors.toList()); + + Boolean like = user != null ? project.getLikes().stream().anyMatch(likes -> likes.getUser().getId().equals(user.getId())) : false; + Boolean bookMark = user != null ? project.getFavorites().stream().anyMatch(favoriteProject -> favoriteProject.getUser().getId().equals(user.getId())) : false; + + List commentResponseList = project.getComments().stream() + .map(CommentResponse::of) + .collect(Collectors.toList()); + + return new ProjectDetailResponse( + project.getId(), + FileResponse.from(project.getThumbnail()), + FileResponse.from(project.getPoster()), + project.getName(), + project.getType(), + project.getCategory(), + project.getTeam(), + project.getYoutubeId(), + project.getYear(), + project.getAwardStatus(), + studentNames, + professorNames, + project.getLikes().size(), + like, + bookMark, + commentResponseList, + project.getUrl(), + project.getDescription() + ); + } +} diff --git a/src/main/java/com/scg/stop/project/dto/response/ProjectExcelResponse.java b/src/main/java/com/scg/stop/project/dto/response/ProjectExcelResponse.java new file mode 100644 index 00000000..78fa67b3 --- /dev/null +++ b/src/main/java/com/scg/stop/project/dto/response/ProjectExcelResponse.java @@ -0,0 +1,10 @@ +package com.scg.stop.project.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ProjectExcelResponse { + private int successCount; +} diff --git a/src/main/java/com/scg/stop/project/dto/response/ProjectResponse.java b/src/main/java/com/scg/stop/project/dto/response/ProjectResponse.java new file mode 100644 index 00000000..3ab9626d --- /dev/null +++ b/src/main/java/com/scg/stop/project/dto/response/ProjectResponse.java @@ -0,0 +1,62 @@ +package com.scg.stop.project.dto.response; + +import com.scg.stop.project.domain.*; +import com.scg.stop.user.domain.User; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.List; +import java.util.stream.Collectors; + +@Getter +@AllArgsConstructor +public class ProjectResponse { + private Long id; + private FileResponse thumbnailInfo; + private String projectName; + private String teamName; + private List studentNames; + private List professorNames; + private ProjectType projectType; + private ProjectCategory projectCategory; + private AwardStatus awardStatus; + private Integer year; + private Integer likeCount; + private Boolean like; + private Boolean bookMark; + private String url; + private String description; + + public static ProjectResponse of(User user, Project project) { + List studentNames = project.getMembers().stream() + .filter(member -> member.getRole() == Role.STUDENT) + .map(Member::getName) + .collect(Collectors.toList()); + + List professorNames = project.getMembers().stream() + .filter(member -> member.getRole() == Role.PROFESSOR) + .map(Member::getName) + .collect(Collectors.toList()); + + Boolean like = user != null ? project.getLikes().stream().anyMatch(likes -> likes.getUser().getId().equals(user.getId())) : false; + Boolean bookMark = user != null ? project.getFavorites().stream().anyMatch(favoriteProject -> favoriteProject.getUser().getId().equals(user.getId())) : false; + + return new ProjectResponse( + project.getId(), + FileResponse.from(project.getThumbnail()), + project.getName(), + project.getTeam(), + studentNames, + professorNames, + project.getType(), + project.getCategory(), + project.getAwardStatus(), + project.getYear(), + project.getLikes().size(), + like, + bookMark, + project.getUrl(), + project.getDescription() + ); + } +} diff --git a/src/main/java/com/scg/stop/project/repository/CommentRepository.java b/src/main/java/com/scg/stop/project/repository/CommentRepository.java new file mode 100644 index 00000000..0939c74b --- /dev/null +++ b/src/main/java/com/scg/stop/project/repository/CommentRepository.java @@ -0,0 +1,7 @@ +package com.scg.stop.project.repository; + +import com.scg.stop.project.domain.Comment; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CommentRepository extends JpaRepository { +} diff --git a/src/main/java/com/scg/stop/project/repository/FavoriteProjectRepository.java b/src/main/java/com/scg/stop/project/repository/FavoriteProjectRepository.java new file mode 100644 index 00000000..b69bd2d2 --- /dev/null +++ b/src/main/java/com/scg/stop/project/repository/FavoriteProjectRepository.java @@ -0,0 +1,18 @@ +package com.scg.stop.project.repository; + +import com.scg.stop.project.domain.FavoriteProject; +import com.scg.stop.project.domain.Project; +import com.scg.stop.user.domain.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface FavoriteProjectRepository extends JpaRepository { + Optional findByProjectIdAndUserId(Long projectId, Long userId); + + @Query("SELECT f.project FROM FavoriteProject f WHERE f.user = :user") + List findAllByUser(@Param("user") User user); +} diff --git a/src/main/java/com/scg/stop/project/repository/InquiryReplyRepository.java b/src/main/java/com/scg/stop/project/repository/InquiryReplyRepository.java new file mode 100644 index 00000000..71a1e5cb --- /dev/null +++ b/src/main/java/com/scg/stop/project/repository/InquiryReplyRepository.java @@ -0,0 +1,14 @@ +package com.scg.stop.project.repository; + +import com.scg.stop.project.domain.InquiryReply; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +public interface InquiryReplyRepository extends JpaRepository { + + // find by InquiryId + @Query("select ir from InquiryReply ir where ir.inquiry.id = :inquiryId") + InquiryReply findByInquiryId(Long inquiryId); + + +} diff --git a/src/main/java/com/scg/stop/project/repository/InquiryRepository.java b/src/main/java/com/scg/stop/project/repository/InquiryRepository.java new file mode 100644 index 00000000..e6eefe4e --- /dev/null +++ b/src/main/java/com/scg/stop/project/repository/InquiryRepository.java @@ -0,0 +1,26 @@ +package com.scg.stop.project.repository; + +import com.scg.stop.project.domain.Inquiry; +import com.scg.stop.user.domain.User; +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.util.List; + +public interface InquiryRepository extends JpaRepository { + @Query("SELECT i FROM Inquiry i " + + "WHERE (:searchScope IS NULL OR " + + "(:searchScope = 'content' AND (:searchTerm IS NULL OR i.content LIKE %:searchTerm%)) " + + "OR (:searchScope = 'title' AND (:searchTerm IS NULL OR i.title LIKE %:searchTerm%)) " + + "OR (:searchScope = 'author' AND (:searchTerm IS NULL OR i.user.name LIKE %:searchTerm%)) " + + "OR (:searchScope = 'both' AND (:searchTerm IS NULL OR i.title LIKE %:searchTerm% OR i.content LIKE %:searchTerm%)))") + Page findInquiries( + @Param("searchTerm") String searchTerm, + @Param("searchScope") String searchScope, + Pageable pageable); + + List findByUser(User user); +} diff --git a/src/main/java/com/scg/stop/project/repository/LikeRepository.java b/src/main/java/com/scg/stop/project/repository/LikeRepository.java new file mode 100644 index 00000000..79bc2cdc --- /dev/null +++ b/src/main/java/com/scg/stop/project/repository/LikeRepository.java @@ -0,0 +1,11 @@ +package com.scg.stop.project.repository; + +import com.scg.stop.project.domain.Likes; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface LikeRepository extends JpaRepository { + Optional findByProjectIdAndUserId(Long projectId, Long userId); +} + diff --git a/src/main/java/com/scg/stop/project/repository/MemberRepository.java b/src/main/java/com/scg/stop/project/repository/MemberRepository.java new file mode 100644 index 00000000..fb164509 --- /dev/null +++ b/src/main/java/com/scg/stop/project/repository/MemberRepository.java @@ -0,0 +1,7 @@ +package com.scg.stop.project.repository; + +import com.scg.stop.project.domain.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberRepository extends JpaRepository { +} diff --git a/src/main/java/com/scg/stop/project/repository/ProjectRepository.java b/src/main/java/com/scg/stop/project/repository/ProjectRepository.java new file mode 100644 index 00000000..b0d60d48 --- /dev/null +++ b/src/main/java/com/scg/stop/project/repository/ProjectRepository.java @@ -0,0 +1,37 @@ +package com.scg.stop.project.repository; + + +import com.scg.stop.project.domain.Project; +import com.scg.stop.project.domain.ProjectCategory; +import com.scg.stop.project.domain.ProjectType; +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.util.List; + +public interface ProjectRepository extends JpaRepository { +// @EntityGraph(attributePaths = {"thumbnail", "members"}) + @Query("SELECT p FROM Project p " + + "WHERE (:title IS NULL OR p.name LIKE %:title%) " + + "AND (:year IS NULL OR p.year IN :year) " + + "AND (:category IS NULL OR p.category IN :category)" + + "AND (:type IS NULL OR p.type IN :type)") + Page findProjects( + @Param("title") String title, + @Param("year") List year, + @Param("category") List category, + @Param("type") List type, + Pageable pageable + ); + + @Query("SELECT p FROM Project p " + + "WHERE p.year = :year " + + "AND p.awardStatus != com.scg.stop.project.domain.AwardStatus.NONE") + Page findAwardProjects( + @Param("year") Integer year, + Pageable pageable + ); +} \ No newline at end of file diff --git a/src/main/java/com/scg/stop/project/service/InquiryService.java b/src/main/java/com/scg/stop/project/service/InquiryService.java new file mode 100644 index 00000000..cf3aa993 --- /dev/null +++ b/src/main/java/com/scg/stop/project/service/InquiryService.java @@ -0,0 +1,206 @@ +package com.scg.stop.project.service; + +import com.scg.stop.global.exception.BadRequestException; +import com.scg.stop.global.exception.ExceptionCode; +import com.scg.stop.global.infrastructure.EmailService; +import com.scg.stop.project.domain.Inquiry; +import com.scg.stop.project.domain.InquiryReply; +import com.scg.stop.project.dto.request.InquiryReplyRequest; +import com.scg.stop.project.dto.request.InquiryRequest; +import com.scg.stop.project.dto.response.InquiryDetailResponse; +import com.scg.stop.project.dto.response.InquiryReplyResponse; +import com.scg.stop.project.dto.response.InquiryResponse; +import com.scg.stop.project.repository.InquiryReplyRepository; +import com.scg.stop.project.repository.InquiryRepository; +import com.scg.stop.user.domain.User; +import com.scg.stop.user.domain.UserType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class InquiryService { + + private final InquiryRepository inquiryRepository; + private final InquiryReplyRepository inquiryReplyRepository; + private final EmailService emailService; + + // 문의 목록 조회 + @Transactional(readOnly = true) + public Page getInquiryList(String searchTerm, String searchScope, Pageable pageable) { + + // If no searchTerm is provided, set searchScope to null + if (searchTerm == null || searchTerm.isEmpty()) { + searchScope = null; + } + + Page inquiries = inquiryRepository.findInquiries(searchTerm, searchScope, pageable); + return inquiries.map(inquiry -> + InquiryResponse.of( + inquiry.getId(), + inquiry.getUser() == null ? null : inquiry.getUser().getName(), + inquiry.getTitle(), + inquiry.getCreatedAt() + ) + ); + } + + // 문의 상세 조회 + @Transactional(readOnly = true) + public InquiryDetailResponse getInquiry(Long inquiryId, User user) { + Inquiry inquiry = findInquiryById(inquiryId); + + if (user.getUserType() != UserType.ADMIN) { + if (inquiry.getUser() == null || !inquiry.getUser().getId().equals(user.getId())) { + throw new BadRequestException(ExceptionCode.UNAUTHORIZED_USER); + } + } + + + return InquiryDetailResponse.of( + inquiry.getId(), + inquiry.getUser() == null ? null : inquiry.getUser().getName(), + inquiry.getProject().getId(), + inquiry.getProject().getName(), + inquiry.getTitle(), + inquiry.getContent(), + inquiry.getReply() != null, + inquiry.getCreatedAt(), + inquiry.getUpdatedAt() + ); + } + + // 문의 수정 + // TODO: Email 전송 로직 추가 + public InquiryDetailResponse updateInquiry(Long inquiryId, User user, InquiryRequest inquiryUpdateRequest) { + Inquiry inquiry = findInquiryById(inquiryId); + + if (user.getUserType() != UserType.ADMIN) { + if (inquiry.getUser() == null || !inquiry.getUser().getId().equals(user.getId())) { + throw new BadRequestException(ExceptionCode.UNAUTHORIZED_USER); + } + } + + + inquiry.updateInquiry(inquiryUpdateRequest.getTitle(), inquiryUpdateRequest.getContent()); + + return InquiryDetailResponse.of( + inquiry.getId(), + inquiry.getUser() == null ? null : inquiry.getUser().getName(), + inquiry.getProject().getId(), + inquiry.getProject().getName(), + inquiry.getTitle(), + inquiry.getContent(), + inquiry.getReply() != null, + inquiry.getCreatedAt(), + inquiry.getUpdatedAt() + ); + } + + // 문의 삭제 + public void deleteInquiry(Long inquiryId, User user) { + Inquiry inquiry = findInquiryById(inquiryId); + + if (user.getUserType() != UserType.ADMIN) { + if (inquiry.getUser() == null || !inquiry.getUser().getId().equals(user.getId())) { + throw new BadRequestException(ExceptionCode.UNAUTHORIZED_USER); + } + } + + inquiryRepository.delete(inquiry); + } + + // 문의 답변 등록 + public InquiryReplyResponse createInquiryReply(Long inquiryId, InquiryReplyRequest inquiryReplyCreateRequest) { + Inquiry inquiry = findInquiryById(inquiryId); + + if (inquiry.getReply() != null) { + throw new BadRequestException(ExceptionCode.ALREADY_EXIST_INQUIRY_REPLY); + } + + InquiryReply inquiryReply = InquiryReply.createInquiryReply( + inquiryReplyCreateRequest.getTitle(), + inquiryReplyCreateRequest.getContent(), + inquiry + ); + inquiryReplyRepository.save(inquiryReply); + + if (inquiry.getUser() != null) { + emailService.sendEmail(inquiry.getUser().getEmail(), inquiryReply.getTitle(), inquiryReply.getContent()); + } + + return InquiryReplyResponse.of( + inquiryReply.getId(), + inquiryReply.getTitle(), + inquiryReply.getContent() + ); + } + + // 문의 답변 조회 + @Transactional(readOnly = true) + public InquiryReplyResponse getInquiryReply(Long inquiryId, User user) { + Inquiry inquiry = findInquiryById(inquiryId); + + if (user.getUserType() != UserType.ADMIN) { + if (inquiry.getUser() == null || !inquiry.getUser().getId().equals(user.getId())) { + throw new BadRequestException(ExceptionCode.UNAUTHORIZED_USER); + } + } + + InquiryReply inquiryReply = inquiry.getReply(); + + if (inquiryReply == null) { + throw new BadRequestException(ExceptionCode.NOT_FOUND_INQUIRY_REPLY); + } + + return InquiryReplyResponse.of( + inquiryReply.getId(), + inquiryReply.getTitle(), + inquiryReply.getContent() + ); + } + + // 문의 답변 수정 + // TODO: Email 전송 로직 추가 + public InquiryReplyResponse updateInquiryReply(Long inquiryId, InquiryReplyRequest inquiryReplyUpdateRequest) { + Inquiry inquiry = findInquiryById(inquiryId); + InquiryReply inquiryReply = inquiry.getReply(); + + if (inquiryReply == null) { + throw new BadRequestException(ExceptionCode.NOT_FOUND_INQUIRY_REPLY); + } + + inquiryReply.updateInquiryResponse( + inquiryReplyUpdateRequest.getTitle(), + inquiryReplyUpdateRequest.getContent() + ); + + return InquiryReplyResponse.of( + inquiryReply.getId(), + inquiryReply.getTitle(), + inquiryReply.getContent() + ); + } + + // 문의 답변 삭제 + public void deleteInquiryReply(Long inquiryId) { + Inquiry inquiry = findInquiryById(inquiryId); + InquiryReply inquiryReply = inquiry.getReply(); + + if (inquiryReply == null) { + throw new BadRequestException(ExceptionCode.NOT_FOUND_INQUIRY_REPLY); + } + + inquiry.deleteReply(); + } + + // helper method for finding inquiry by id + private Inquiry findInquiryById(Long inquiryId) { + return inquiryRepository.findById(inquiryId) + .orElseThrow(() -> new BadRequestException(ExceptionCode.NOT_FOUND_INQUIRY)); + } +} diff --git a/src/main/java/com/scg/stop/project/service/ProjectExcelService.java b/src/main/java/com/scg/stop/project/service/ProjectExcelService.java new file mode 100644 index 00000000..af923ae5 --- /dev/null +++ b/src/main/java/com/scg/stop/project/service/ProjectExcelService.java @@ -0,0 +1,206 @@ +package com.scg.stop.project.service; + +import com.scg.stop.global.exception.BadRequestException; +import com.scg.stop.global.exception.InternalServerErrorException; +import com.scg.stop.project.domain.AwardStatus; +import com.scg.stop.project.domain.ProjectCategory; +import com.scg.stop.project.domain.ProjectType; +import com.scg.stop.project.domain.Role; +import com.scg.stop.project.dto.request.MemberRequest; +import com.scg.stop.project.dto.request.ProjectRequest; +import com.scg.stop.project.dto.response.FileResponse; +import com.scg.stop.project.dto.response.ProjectExcelResponse; +import lombok.RequiredArgsConstructor; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellType; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.xssf.usermodel.XSSFSheet; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.text.Normalizer; +import java.util.*; + +import static com.scg.stop.global.exception.ExceptionCode.*; + +@Service +@Transactional +@RequiredArgsConstructor +public class ProjectExcelService { + + private final ProjectService projectService; + + private static final int COLUMN_SIZE = 13; + + public ProjectExcelResponse createProjectExcel(MultipartFile excelFile, List thumbnails, List posters) { + try (XSSFWorkbook workbook = new XSSFWorkbook(excelFile.getInputStream())) { + XSSFSheet sheet = workbook.getSheetAt(0); + validateRequestSize(sheet, thumbnails.size(), posters.size()); + + // ProjectRequest DTO로 변환 + Map headerMap = getHeaderMap(sheet); + List projectRequests = convertExcelToDtoList(sheet, headerMap, thumbnails, posters); + validateNoDuplicateFile(projectRequests); + + // 프로젝트 생성 + projectRequests.forEach(projectRequest -> projectService.createProject(projectRequest, null)); + return new ProjectExcelResponse(projectRequests.size()); + } catch (IOException e) { + throw new InternalServerErrorException(INVALID_EXCEL); + } catch (BadRequestException e) { + throw new BadRequestException(INVALID_EXCEL_FORMAT, e.getMessage()); + } + } + + private List convertExcelToDtoList(XSSFSheet sheet, Map headerMap, List thumbnails, List posters) { + List projectRequests = new ArrayList<>(); + for (int rowIndex = 1; rowIndex <= sheet.getLastRowNum(); rowIndex++) { + Row row = sheet.getRow(rowIndex); + if (isRowEmpty(row)) { + continue; + } + try { + validateNoEmptyShell(row); + projectRequests.add(convertRowToDto(row, headerMap, thumbnails, posters)); + } catch (BadRequestException e) { + throw new BadRequestException(INVALID_EXCEL_FORMAT, String.format("%d행의 형식이 올바르지 않습니다: %s", rowIndex + 1, e.getMessage())); + } + } + return projectRequests; + } + + private ProjectRequest convertRowToDto(Row row, Map headerMap, List thumbnails, List posters) { + String projectName = row.getCell(headerMap.get("프로젝트명")).getStringCellValue().strip(); + String projectTypeStr = row.getCell(headerMap.get("프로젝트 종류")).getStringCellValue().strip(); + String projectCategoryStr = row.getCell(headerMap.get("프로젝트 분야")).getStringCellValue().strip(); + String teamName = row.getCell(headerMap.get("참가팀명")).getStringCellValue().strip(); + String professors = row.getCell(headerMap.get("담당 교수")).getStringCellValue().strip(); + String students = row.getCell(headerMap.get("학생")).getStringCellValue().strip(); + String youtubeId = row.getCell(headerMap.get("유튜브 아이디")).getStringCellValue().strip(); + String url = row.getCell(headerMap.get("웹사이트")).getStringCellValue().strip(); + String description = row.getCell(headerMap.get("간략 설명")).getStringCellValue().strip(); + Integer year = validateAndParseNumericCellValue(row.getCell(headerMap.get("프로젝트 년도"))); + String awardStatusStr = row.getCell(headerMap.get("수상 내역")).getStringCellValue().strip(); + String thumbnailImageName = row.getCell(headerMap.get("썸네일 이미지")).getStringCellValue().strip(); + String posterImageName = row.getCell(headerMap.get("포스터 이미지")).getStringCellValue().strip(); + + // 이미지 이름으로 아이디 검색 + Long thumbnailId = thumbnails.stream() + .filter(thumbnail -> normalize(thumbnail.getName()).equals(normalize(thumbnailImageName))) + .map(FileResponse::getId) + .findFirst() + .orElseThrow(() -> new BadRequestException(INVALID_THUMBNAIL_NAME)); + Long posterId = posters.stream() + .filter(poster -> normalize(poster.getName()).equals(normalize(posterImageName))) + .map(FileResponse::getId) + .findFirst() + .orElseThrow(() -> new BadRequestException(INVALID_POSTER_NAME)); + + // enum 변환 + ProjectType projectType = ProjectType.fromKoreanName(projectTypeStr); + ProjectCategory projectCategory = ProjectCategory.fromKoreanName(projectCategoryStr); + AwardStatus awardStatus = AwardStatus.fromKoreanName(awardStatusStr); + + // 교수 이름, 학생 이름 -> List + List members = new ArrayList<>(); + Arrays.stream(professors.split(",")).forEach(name -> members.add(new MemberRequest(name.strip(), Role.PROFESSOR))); + Arrays.stream(students.split(",")).forEach(name -> members.add(new MemberRequest(name.strip(), Role.STUDENT))); + + return new ProjectRequest( + thumbnailId, + posterId, + projectName, + projectType, + projectCategory, + teamName, + youtubeId, + year, + awardStatus, + members, + url, + description); + } + + private Integer validateAndParseNumericCellValue(Cell cell) { + try { + return (int) cell.getNumericCellValue(); + } catch (Exception e) { + throw new BadRequestException(INVALID_YEAR_FORMAT); + } + } + + private void validateNoEmptyShell(Row row) { + short lastCellNum = row.getLastCellNum(); + if (lastCellNum != COLUMN_SIZE) { + throw new BadRequestException(EMPTY_CELL); + } + for (int cellIndex = 0; cellIndex < lastCellNum; cellIndex++) { + Cell cell = row.getCell(cellIndex); + if (cell == null || cell.getCellType() == CellType.BLANK) { + throw new BadRequestException(EMPTY_CELL); + } + } + } + + private void validateRequestSize(Sheet sheet, int thumbnails, int posters) { + int dataRowCount = 0; + for (int rowIndex = 1; rowIndex <= sheet.getLastRowNum(); rowIndex++) { + if (!isRowEmpty(sheet.getRow(rowIndex))) { + dataRowCount++; + } + } + if (dataRowCount != thumbnails || dataRowCount != posters) { + throw new BadRequestException(INVALID_FILE_SIZE); + } + } + + private void validateNoDuplicateFile(List projectRequests) { + List thumbnailIdList = projectRequests.stream().map(ProjectRequest::getThumbnailId).toList(); + Set thumbnailIdSet = Set.copyOf(thumbnailIdList); + if (thumbnailIdList.size() != thumbnailIdSet.size()) { + throw new BadRequestException(DUPLICATE_THUMBNAIL_ID); + } + + List posterIdList = projectRequests.stream().map(ProjectRequest::getPosterId).toList(); + Set posterIdSet = Set.copyOf(posterIdList); + if (posterIdList.size() != posterIdSet.size()) { + throw new BadRequestException(DUPLICATE_POSTER_ID); + } + } + + private boolean isRowEmpty(Row row) { + if (row == null) { + return true; + } + for (Cell cell : row) { + if (!isCellEmpty(cell)) { + return false; + } + } + return true; + } + + private boolean isCellEmpty(Cell cell) { + return cell == null || cell.getCellType() == CellType.BLANK; + } + + /** + * 문자열 유니코드 정규화 + */ + private String normalize(String input) { + return Normalizer.normalize(input, Normalizer.Form.NFC); + } + + private Map getHeaderMap(XSSFSheet sheet) { + Map headerMap = new HashMap<>(); + Row headerRow = sheet.getRow(0); + for (Cell cell : headerRow) { + headerMap.put(cell.getStringCellValue(), cell.getColumnIndex()); + } + return headerMap; + } +} diff --git a/src/main/java/com/scg/stop/project/service/ProjectService.java b/src/main/java/com/scg/stop/project/service/ProjectService.java new file mode 100644 index 00000000..2165e3a7 --- /dev/null +++ b/src/main/java/com/scg/stop/project/service/ProjectService.java @@ -0,0 +1,223 @@ +package com.scg.stop.project.service; + + +import com.scg.stop.event.domain.EventPeriod; +import com.scg.stop.event.repository.EventPeriodRepository; +import com.scg.stop.file.domain.File; +import com.scg.stop.file.repository.FileRepository; +import com.scg.stop.project.domain.*; +import com.scg.stop.project.dto.request.CommentRequest; +import com.scg.stop.project.dto.request.ProjectRequest; +import com.scg.stop.project.dto.response.CommentResponse; +import com.scg.stop.project.dto.response.ProjectDetailResponse; +import com.scg.stop.project.dto.response.ProjectResponse; +import com.scg.stop.project.repository.*; +import com.scg.stop.global.exception.BadRequestException; +import com.scg.stop.global.exception.ExceptionCode; +import com.scg.stop.global.infrastructure.EmailService; +import com.scg.stop.project.domain.Inquiry; +import com.scg.stop.project.domain.Project; +import com.scg.stop.project.dto.request.InquiryRequest; +import com.scg.stop.project.dto.response.InquiryDetailResponse; +import com.scg.stop.project.repository.ProjectRepository; +import com.scg.stop.user.domain.User; +import com.scg.stop.user.repository.UserRepository; +import jakarta.persistence.EntityManager; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.annotation.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@Transactional +@RequiredArgsConstructor +public class ProjectService { + private final ProjectRepository projectRepository; + private final FileRepository fileRepository; + private final FavoriteProjectRepository favoriteProjectRepository; + private final LikeRepository likeRepository; + private final CommentRepository commentRepository; + private final EventPeriodRepository eventPeriodRepository; + private final UserRepository userRepository; + private final InquiryRepository inquiryRepository; + private final MemberRepository memberRepository; + private final EmailService emailService; + + @Value("${spring.mail.adminEmail}") + private String adminEmail; + + @Autowired + private EntityManager entityManager; + + @Transactional(readOnly = true) + public Page getProjects(String title, List year, List category, List type, Pageable pageable, User user){ + Page projects = projectRepository.findProjects(title, year, category, type, pageable); + Page projectResponses = projects.map(project -> ProjectResponse.of(user, project)); + return projectResponses; + } + + public ProjectDetailResponse createProject(ProjectRequest projectRequest, User user) { + File thumbnail = fileRepository.findById(projectRequest.getThumbnailId()) + .orElseThrow(() -> new BadRequestException(ExceptionCode.NOT_FOUND_PROJECT_THUMBNAIL)); + File poster = fileRepository.findById(projectRequest.getPosterId()) + .orElseThrow(() -> new BadRequestException(ExceptionCode.NOT_FOUND_PROJECT_POSTER)); + + Project project = projectRequest.toEntity(null, thumbnail, poster); + projectRepository.save(project); + + return ProjectDetailResponse.of(user, project); + } + + @Transactional(readOnly = true) + public ProjectDetailResponse getProject(Long projectId, User user) { + Project project = projectRepository.findById(projectId) + .orElseThrow(() -> new BadRequestException(ExceptionCode.NOT_FOUND_PROJECT)); + return ProjectDetailResponse.of(user, project); + } + + public ProjectDetailResponse updateProject(Long projectId, ProjectRequest projectRequest, User user) { + Project project = projectRepository.findById(projectId) + .orElseThrow(() -> new BadRequestException(ExceptionCode.NOT_FOUND_PROJECT)); + + File thumbnail = fileRepository.findById(projectRequest.getThumbnailId()) + .orElseThrow(() -> new BadRequestException(ExceptionCode.NOT_FOUND_PROJECT_THUMBNAIL)); + File poster = fileRepository.findById(projectRequest.getPosterId()) + .orElseThrow(() -> new BadRequestException(ExceptionCode.NOT_FOUND_PROJECT_POSTER)); + + Project newProject = projectRequest.toEntity(projectId, thumbnail, poster); + + memberRepository.deleteAll(project.getMembers()); + project.update(newProject); + + return ProjectDetailResponse.of(user, project); + } + + public void deleteProject(Long projectId) { + Project project = projectRepository.findById(projectId) + .orElseThrow(() -> new BadRequestException(ExceptionCode.NOT_FOUND_PROJECT)); + + projectRepository.delete(project); + } + + public void createProjectFavorite(Long projectId, User user){ + Project project = projectRepository.findById(projectId) + .orElseThrow(() -> new BadRequestException(ExceptionCode.NOT_FOUND_PROJECT)); + + try { + FavoriteProject newFavoriteProject = new FavoriteProject(null, project, user); + favoriteProjectRepository.save(newFavoriteProject); + project.addFavoriteProject(newFavoriteProject); + userRepository.save(user); + } catch (DataIntegrityViolationException e){ // DB 사이드에서 동시성 처리 + throw new BadRequestException(ExceptionCode.ALREADY_FAVORITE_PROJECT); + } + } + + public void deleteProjectFavorite(Long projectId, User user){ + FavoriteProject favoriteProject = favoriteProjectRepository.findByProjectIdAndUserId(projectId, user.getId()) + .orElseThrow(() -> new BadRequestException(ExceptionCode.NOT_FOUND_FAVORITE_PROJECT)); + Project project = projectRepository.findById(projectId) + .orElseThrow(() -> new BadRequestException(ExceptionCode.NOT_FOUND_PROJECT)); + + favoriteProjectRepository.delete(favoriteProject); + project.removeFavoriteProject(favoriteProject); + //userRepository.save(user); + } + + public void createProjectLike(Long projectId, User user){ + EventPeriod eventPeriod = eventPeriodRepository.findByYear(LocalDateTime.now().getYear()) + .orElseThrow(() -> new BadRequestException(ExceptionCode.NOT_FOUND_EVENT_PERIOD)); + if (eventPeriod.getStart().isAfter(LocalDateTime.now()) || eventPeriod.getEnd().isBefore(LocalDateTime.now())){ + throw new BadRequestException(ExceptionCode.NOT_EVENT_PERIOD); + } + + Project project = projectRepository.findById(projectId) + .orElseThrow(() -> new BadRequestException(ExceptionCode.NOT_FOUND_PROJECT)); + + try { + Likes newLike = new Likes(null, project, user); + likeRepository.save(newLike); + project.addLikes(newLike); + userRepository.save(user); + } catch (DataIntegrityViolationException e){ // DB 사이드에서 동시성 처리 + throw new BadRequestException(ExceptionCode.ALREADY_LIKE_PROJECT); + } + } + + public void deleteProjectLike(Long projectId, User user){ + EventPeriod eventPeriod = eventPeriodRepository.findByYear(LocalDateTime.now().getYear()) + .orElseThrow(() -> new BadRequestException(ExceptionCode.NOT_FOUND_EVENT_PERIOD)); + if (eventPeriod.getStart().isAfter(LocalDateTime.now()) || eventPeriod.getEnd().isBefore(LocalDateTime.now())){ + throw new BadRequestException(ExceptionCode.NOT_EVENT_PERIOD); + } + + Likes like = likeRepository.findByProjectIdAndUserId(projectId, user.getId()) + .orElseThrow(() -> new BadRequestException(ExceptionCode.NOT_FOUND_LIKE_PROJECT)); + Project project = projectRepository.findById(projectId) + .orElseThrow(() -> new BadRequestException(ExceptionCode.NOT_FOUND_PROJECT)); + + likeRepository.delete(like); + project.removeLikes(like); + //userRepository.save(user); + } + + public CommentResponse createProjectComment(Long projectId, User user, CommentRequest commentRequest){ + Project project = projectRepository.findById(projectId) + .orElseThrow(() -> new BadRequestException(ExceptionCode.NOT_FOUND_PROJECT)); + + Comment comment = commentRequest.toEntity(project, user); + commentRepository.save(comment); + + return CommentResponse.of(comment); + } + + public void deleteProjectComment(Long projectId, Long commentId, User user){ + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new BadRequestException(ExceptionCode.NOT_FOUND_COMMENT)); + if (!comment.getUser().getId().equals(user.getId())) { + throw new BadRequestException(ExceptionCode.NOT_MATCH_USER); + } + + commentRepository.delete(comment); + } + + public Page getAwardProjects(Integer year, Pageable page, User user){ + Page projects = projectRepository.findAwardProjects(year, page); + Page projectResponses = projects.map(project -> ProjectResponse.of(user, project)); + return projectResponses; + } + + public InquiryDetailResponse createProjectInquiry(Long projectId, User user, InquiryRequest inquiryRequest) { + Project project = projectRepository.findById(projectId) + .orElseThrow(() -> new BadRequestException(ExceptionCode.NOT_FOUND_PROJECT)); + Inquiry inquiry = Inquiry.createInquiry( + inquiryRequest.getTitle(), + inquiryRequest.getContent(), + project, + user + ); + inquiryRepository.save(inquiry); + emailService.sendEmail(adminEmail, inquiry.getTitle(), inquiry.getContent()); + return InquiryDetailResponse.of( + inquiry.getId(), + user.getName(), + project.getId(), + project.getName(), + inquiry.getTitle(), + inquiry.getContent(), + false, // 막 생성된 문의는 답변이 달리지 않은 상태 + inquiry.getCreatedAt(), + inquiry.getUpdatedAt() + ); + + } +} diff --git a/src/main/java/com/scg/stop/proposal/controller/ProposalController.java b/src/main/java/com/scg/stop/proposal/controller/ProposalController.java new file mode 100644 index 00000000..846e634d --- /dev/null +++ b/src/main/java/com/scg/stop/proposal/controller/ProposalController.java @@ -0,0 +1,106 @@ +package com.scg.stop.proposal.controller; + +import com.scg.stop.auth.annotation.AuthUser; +import com.scg.stop.proposal.domain.request.CreateProposalRequest; +import com.scg.stop.proposal.domain.request.ProposalReplyRequest; +import com.scg.stop.proposal.domain.response.ProposalDetailResponse; +import com.scg.stop.proposal.domain.response.ProposalReplyResponse; +import com.scg.stop.proposal.domain.response.ProposalResponse; +import com.scg.stop.proposal.service.ProposalService; +import com.scg.stop.user.domain.AccessType; +import com.scg.stop.user.domain.User; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/proposals") +public class ProposalController { + + private final ProposalService proposalService; + + @GetMapping() + public ResponseEntity> getProposals(@AuthUser(accessType = {AccessType.COMPANY, AccessType.ADMIN}) User user, + @RequestParam(value = "scope", required = false) String scope, + @RequestParam(value = "terms", required = false) String term, + @PageableDefault(page = 0, size = 10) Pageable pageable) { + Page proposalResponse = proposalService.getProposalList(scope, term, pageable, user); + return ResponseEntity.status(HttpStatus.OK).body(proposalResponse); + } + + @GetMapping("/{proposalId}") + public ResponseEntity getProposal(@AuthUser(accessType = {AccessType.COMPANY, AccessType.ADMIN}) User user, + @PathVariable("proposalId") Long proposalId) { + ProposalDetailResponse proposalDetailResponse = proposalService.getProposalDetail(proposalId, user); + return ResponseEntity.status(HttpStatus.OK).body(proposalDetailResponse); + } + + @PostMapping() + public ResponseEntity createProposal(@AuthUser(accessType = {AccessType.COMPANY, AccessType.ADMIN}) User user, + @RequestBody @Valid CreateProposalRequest proposalCreateRequest) { + ProposalDetailResponse proposalDetailResponse = proposalService.createProposal(user, proposalCreateRequest); + return ResponseEntity.status(HttpStatus.CREATED).body(proposalDetailResponse); + } + + @PutMapping("/{proposalId}") + public ResponseEntity updateProposal(@AuthUser(accessType = {AccessType.COMPANY, AccessType.ADMIN}) User user, + @PathVariable("proposalId") Long proposalId, + @RequestBody @Valid CreateProposalRequest proposalUpdateRequest) { + ProposalDetailResponse proposalDetailResponse = proposalService.updateProposal(proposalId, + proposalUpdateRequest, user); + return ResponseEntity.status(HttpStatus.OK).body(proposalDetailResponse); + } + + @DeleteMapping("/{proposalId}") + public ResponseEntity deleteProposal(@AuthUser(accessType = {AccessType.ADMIN, AccessType.COMPANY}) User user, + @PathVariable("proposalId") Long proposalId) { + proposalService.deleteProposal(proposalId, user); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + @PostMapping("/{proposalId}/reply") + public ResponseEntity createProposalReply(@AuthUser(accessType = {AccessType.ADMIN}) User user, + @PathVariable("proposalId") Long proposalId, + @RequestBody @Valid ProposalReplyRequest proposalReplyCreateRequest) { + ProposalReplyResponse proposalReplyResponse = proposalService.createProposalReply(proposalId, proposalReplyCreateRequest); + return ResponseEntity.status(HttpStatus.CREATED).body(proposalReplyResponse); + } + + @PutMapping("/{proposalId}/reply/{proposalReplyId}") + public ResponseEntity updateProposalReply(@AuthUser(accessType = {AccessType.ADMIN}) User user, + @PathVariable("proposalId") Long proposalId, + @PathVariable("proposalReplyId") Long proposalReplyId, + @RequestBody @Valid ProposalReplyRequest proposalReplyUpdateRequest) { + + ProposalReplyResponse proposalReplyResponse = proposalService.updateProposalReply(proposalReplyId, proposalReplyUpdateRequest); + return ResponseEntity.status(HttpStatus.OK).body(proposalReplyResponse); + } + @DeleteMapping("/{proposalId}/reply/{proposalReplyId}") + public ResponseEntity deleteProposalReply(@AuthUser(accessType = {AccessType.ADMIN}) User user, + @PathVariable("proposalId") Long proposalId, @PathVariable("proposalReplyId") Long proposalReplyId) { + proposalService.deleteProposalReply(proposalReplyId); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + @GetMapping("/{proposalId}/reply") + public ResponseEntity getProposalReplies(@AuthUser(accessType = {AccessType.ADMIN, AccessType.COMPANY}) User user, + @PathVariable("proposalId") Long proposalId) { +// List proposalReplies = proposalService.getProposalReplies(proposalId); + ProposalReplyResponse proposalReply = proposalService.getProposalReply(proposalId, user); + return ResponseEntity.status(HttpStatus.OK).body(proposalReply); + } +} diff --git a/src/main/java/com/scg/stop/proposal/domain/MailEvent.java b/src/main/java/com/scg/stop/proposal/domain/MailEvent.java new file mode 100644 index 00000000..6ba4c872 --- /dev/null +++ b/src/main/java/com/scg/stop/proposal/domain/MailEvent.java @@ -0,0 +1,20 @@ +package com.scg.stop.proposal.domain; + +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + + +@Getter +public class MailEvent extends ApplicationEvent { + private final String recipient; + private final String subject; + private final String body; + + public MailEvent(Object source, String recipient, String subject, String body) { + super(source); + this.recipient = recipient; + this.subject = subject; + this.body = body; + } + +} diff --git a/src/main/java/com/scg/stop/proposal/domain/Proposal.java b/src/main/java/com/scg/stop/proposal/domain/Proposal.java new file mode 100644 index 00000000..4c7ad734 --- /dev/null +++ b/src/main/java/com/scg/stop/proposal/domain/Proposal.java @@ -0,0 +1,173 @@ +package com.scg.stop.proposal.domain; + +import com.scg.stop.file.domain.File; +import com.scg.stop.global.domain.BaseTimeEntity; +import com.scg.stop.project.domain.ProjectType; +import com.scg.stop.user.domain.User; +import com.scg.stop.user.domain.UserType; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +@Entity +@Getter +@NoArgsConstructor(access = PROTECTED) +public class Proposal extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @Column(nullable = false) + private String title; + + @Column(nullable = false) + private String projectTypes; + + @Column(nullable = false) + private String email; + + @Column() + private String webSite; + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @Column(nullable = false, columnDefinition = "TINYINT(1)") + private boolean isVisible; + + @Column(nullable = false, columnDefinition = "TINYINT(1)") + private boolean isAnonymous; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "user_id") + private User user; + + @OneToMany(fetch = LAZY, mappedBy = "proposal", cascade = CascadeType.ALL, orphanRemoval = true) + private List files = new ArrayList<>(); + + @OneToOne(fetch = LAZY, mappedBy = "proposal", cascade = CascadeType.ALL, orphanRemoval = true) + private ProposalReply proposalReply; + + + public static final String HIDDEN_TITLE = "비밀글"; + public static final String HIDDEN_CONTENT = "비밀글입니다"; + public static final String HIDDEN_EMAIL = "비공개 이메일"; + public static final String HIDDEN_WEBSITE = "비공개 웹사이트"; + public static final String HIDDEN_USER = "익명 사용자"; + + + private Proposal(User user, String title, String projectTypes, String email, String website, String content, + Boolean isVisible, Boolean isAnonymous, List files) { + this.title = title; + this.projectTypes = projectTypes; + this.email = email; + this.webSite = website; + this.content = content; + this.isAnonymous = isAnonymous; + this.isVisible = isVisible; + this.user = user; + changeFileMapping(files); + } + + public static Proposal createProposal(User user, String title, String projectTypes, String email, String website, + String content, Boolean isVisible, Boolean isAnonymous, List files) { + return new Proposal( + user, + title, + projectTypes, + email, + website, + content, + isVisible, + isAnonymous, + files + ); + } + + public void update(String title, + String projectTypes, + String email, + String website, + String content, + Boolean isVisible, + Boolean isAnonymous, + List files + ) { + this.title = title; + this.projectTypes = projectTypes; + this.email = email; + this.webSite = website; + this.content = content; + this.isAnonymous = isAnonymous; + this.isVisible = isVisible; + changeFileMapping(files); + } + + public void setUser(User user) { + this.user = user; + } + + public static String convertProjectTypesToString(List proposalTypes) { + if (proposalTypes == null || proposalTypes.isEmpty()) { + return ""; + } + return proposalTypes.stream() + .map(Enum::name) + .collect(Collectors.joining(",")); + } + + public static List convertStringToProjectTypes(String proposalTypes) { + if (proposalTypes == null || proposalTypes.isEmpty()) { + return new ArrayList<>(); + } + return Arrays.stream(proposalTypes.split(",")) + .map(ProjectType::valueOf) + .collect(Collectors.toList()); + } + + public String getDisplayTitle(User requestUser) { + return shouldHideContent(requestUser) ? HIDDEN_TITLE : this.title; + } + + public String getDisplayContent(User requestUser) { + return shouldHideContent(requestUser) ? HIDDEN_CONTENT : this.content; + } + + public String getDisplayEmail(User requestUser) { + return isAnonymous && !isAuthorized(requestUser) ? HIDDEN_EMAIL : this.email; + } + + public String getDisplayWebsite(User requestUser) { + return isAnonymous && !isAuthorized(requestUser) ? HIDDEN_WEBSITE : this.webSite; + } + + public String getDisplayUser(User requestUser) { + return (isAnonymous || !isVisible) && !isAuthorized(requestUser) ? HIDDEN_USER : user.getName(); + } + + private boolean shouldHideContent(User requestUser) { + return !isVisible && !isAuthorized(requestUser); + } + + public boolean isAuthorized(User requestUser) { + if (user.getId() == null) return false; + return requestUser.getUserType() == UserType.ADMIN || this.user.getId().equals(requestUser.getId()); + } + + private void changeFileMapping(List files) { + for(File file: files) { + file.connectProposal(this); + } + this.files.addAll(files); + } +} diff --git a/src/main/java/com/scg/stop/proposal/domain/ProposalReply.java b/src/main/java/com/scg/stop/proposal/domain/ProposalReply.java new file mode 100644 index 00000000..58404c09 --- /dev/null +++ b/src/main/java/com/scg/stop/proposal/domain/ProposalReply.java @@ -0,0 +1,55 @@ +package com.scg.stop.proposal.domain; + +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import com.scg.stop.global.domain.BaseTimeEntity; +import com.scg.stop.user.domain.User; +import com.scg.stop.user.domain.UserType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = PROTECTED) +public class ProposalReply extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @Column(nullable = false) + private String title; + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @OneToOne(fetch = LAZY) + @JoinColumn(name = "proposal_id") + private Proposal proposal; + private ProposalReply(String title, String content, Proposal proposal) { + this.title = title; + this.content = content; + this.proposal = proposal; + } + public static ProposalReply createProposalReply(String title, String content, Proposal proposal) { + return new ProposalReply(title, content, proposal); + } + + public void update(String title, String content) { + this.title = title; + this.content = content; + } + + public boolean isAuthorized(User requestUser) { + if (proposal.getUser() == null) return false; + return requestUser.getUserType().equals(UserType.ADMIN) || proposal.getUser().getId().equals(requestUser.getId()); + } +} diff --git a/src/main/java/com/scg/stop/proposal/domain/request/CreateProposalRequest.java b/src/main/java/com/scg/stop/proposal/domain/request/CreateProposalRequest.java new file mode 100644 index 00000000..c47149a2 --- /dev/null +++ b/src/main/java/com/scg/stop/proposal/domain/request/CreateProposalRequest.java @@ -0,0 +1,71 @@ +package com.scg.stop.proposal.domain.request; + +import com.scg.stop.project.domain.ProjectType; +import com.scg.stop.global.exception.BadRequestException; +import com.scg.stop.global.exception.ExceptionCode; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; +import lombok.Getter; + + +@Getter +public class CreateProposalRequest { + + private final String webSite; + + @NotBlank(message = "과제 이름을 입력해주세요.") + private final String title; + + @NotBlank(message = "알림을 받을 이메일을 입력해주세요.") + private final String email; + + @NotEmpty(message = "과제 유형을 입력해주세요.") + private final List projectTypes; + + @NotBlank(message = "과제 내용을 입력해주세요.") + private final String content; + + @NotNull(message = "과제의 공개여부를 입력해주세요.") + private Boolean isVisible; + + @NotNull(message = "과제의 익명여부를 입력해주세요.") + private Boolean isAnonymous; + + @Size(min = 1, message = "1개 이상의 파일을 첨부해야 합니다.") + @NotNull(message = "파일을 첨부해주세요.") + private List fileIds; + + public CreateProposalRequest( + String webSite, + String title, + String email, + List projectTypes, + String content, + String isVisible, + String isAnonymous, + List fileIds + ) { + validateAndCastBoolean(isVisible, isAnonymous); + this.webSite = webSite; + this.email = email; + this.title = title; + this.content = content; + this.projectTypes = projectTypes; + this.fileIds = fileIds; + } + + private void validateAndCastBoolean(String isVisible, String isAnonymous) { + if (isVisible.equals("true")) this.isVisible = true; + else if (isVisible.equals("false")) this.isVisible = false; + else throw new BadRequestException(ExceptionCode.INVALID_REQUEST); + + if (isAnonymous.equals("true")) this.isAnonymous = true; + else if (isAnonymous.equals("false")) this.isAnonymous = false; + else throw new BadRequestException(ExceptionCode.INVALID_REQUEST); + + } + +} diff --git a/src/main/java/com/scg/stop/proposal/domain/request/ProposalReplyRequest.java b/src/main/java/com/scg/stop/proposal/domain/request/ProposalReplyRequest.java new file mode 100644 index 00000000..ed5577a8 --- /dev/null +++ b/src/main/java/com/scg/stop/proposal/domain/request/ProposalReplyRequest.java @@ -0,0 +1,17 @@ +package com.scg.stop.proposal.domain.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +public class ProposalReplyRequest { + + @NotBlank(message = "제목을 입력해주세요.") + private final String title; + + @NotBlank(message = "내용을 입력해주세요.") + private final String content; +} diff --git a/src/main/java/com/scg/stop/proposal/domain/response/ProposalDetailResponse.java b/src/main/java/com/scg/stop/proposal/domain/response/ProposalDetailResponse.java new file mode 100644 index 00000000..31ab634d --- /dev/null +++ b/src/main/java/com/scg/stop/proposal/domain/response/ProposalDetailResponse.java @@ -0,0 +1,40 @@ +package com.scg.stop.proposal.domain.response; + +import com.scg.stop.file.domain.File; +import com.scg.stop.file.dto.response.FileResponse; +import com.scg.stop.project.domain.ProjectType; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@Getter +@AllArgsConstructor +public class ProposalDetailResponse { + private Long id; + private String authorName; + private String email; + private String webSite; + private String title; + private List projectTypes; + private String content; + private Boolean replied; + private List files; + + public static ProposalDetailResponse of(Long id, String authorName, String email, String webSite, String title, + List projectTypes, String content, Boolean replied, + List files) { + return new ProposalDetailResponse( + id, + authorName, + email, + webSite, + title, + projectTypes, + content, + replied, + files + ); + } +} diff --git a/src/main/java/com/scg/stop/proposal/domain/response/ProposalReplyResponse.java b/src/main/java/com/scg/stop/proposal/domain/response/ProposalReplyResponse.java new file mode 100644 index 00000000..d1d69197 --- /dev/null +++ b/src/main/java/com/scg/stop/proposal/domain/response/ProposalReplyResponse.java @@ -0,0 +1,19 @@ +package com.scg.stop.proposal.domain.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@Getter +@AllArgsConstructor +public class ProposalReplyResponse { + + private Long id; + private String title; + private String content; + + public static ProposalReplyResponse of(Long id, String title, String content) { + return new ProposalReplyResponse(id, title, content); + } +} diff --git a/src/main/java/com/scg/stop/proposal/domain/response/ProposalResponse.java b/src/main/java/com/scg/stop/proposal/domain/response/ProposalResponse.java new file mode 100644 index 00000000..e13b02cb --- /dev/null +++ b/src/main/java/com/scg/stop/proposal/domain/response/ProposalResponse.java @@ -0,0 +1,28 @@ +package com.scg.stop.proposal.domain.response; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class ProposalResponse { + + private Long id; + private String title; + private String name; + private LocalDateTime createdDate; + + public static ProposalResponse of(Long id, String title, String name, LocalDateTime date) { + return new ProposalResponse( + id, + title, + name, + date + ); + } +} diff --git a/src/main/java/com/scg/stop/proposal/repository/ProposalReplyRepository.java b/src/main/java/com/scg/stop/proposal/repository/ProposalReplyRepository.java new file mode 100644 index 00000000..bb31bf94 --- /dev/null +++ b/src/main/java/com/scg/stop/proposal/repository/ProposalReplyRepository.java @@ -0,0 +1,13 @@ +package com.scg.stop.proposal.repository; + +import com.scg.stop.proposal.domain.Proposal; +import com.scg.stop.proposal.domain.ProposalReply; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +public interface ProposalReplyRepository extends JpaRepository { + +// @Query("select p from ProposalReply p where p.proposal = :proposal") + ProposalReply findByProposal(Proposal proposal); +} diff --git a/src/main/java/com/scg/stop/proposal/repository/ProposalRepository.java b/src/main/java/com/scg/stop/proposal/repository/ProposalRepository.java new file mode 100644 index 00000000..d9dea7a9 --- /dev/null +++ b/src/main/java/com/scg/stop/proposal/repository/ProposalRepository.java @@ -0,0 +1,24 @@ +package com.scg.stop.proposal.repository; + +import com.scg.stop.proposal.domain.Proposal; +import com.scg.stop.user.domain.User; +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.util.List; +import java.util.Optional; + +public interface ProposalRepository extends JpaRepository, ProposalRepositoryCustom { + + //TODO: cursor 기반 pagination 과 성능 비교 + nativeQuery + @Query("SELECT p FROM Proposal p WHERE :title IS NULL OR p.title LIKE %:title%") + Page findProposals(@Param("title") String title, Pageable pageable); + + @Query("SELECT p from Proposal p LEFT JOIN fetch p.files WHERE p.id = :id") + Optional findByIdWithFiles(@Param("id") Long id); + + List findByUser(User user); +} diff --git a/src/main/java/com/scg/stop/proposal/repository/ProposalRepositoryCustom.java b/src/main/java/com/scg/stop/proposal/repository/ProposalRepositoryCustom.java new file mode 100644 index 00000000..f85cd3c6 --- /dev/null +++ b/src/main/java/com/scg/stop/proposal/repository/ProposalRepositoryCustom.java @@ -0,0 +1,9 @@ +package com.scg.stop.proposal.repository; + +import com.scg.stop.proposal.domain.Proposal; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface ProposalRepositoryCustom { + Page filterProposals(String scope, String term, Pageable pageable); +} diff --git a/src/main/java/com/scg/stop/proposal/repository/ProposalRepositoryCustomImpl.java b/src/main/java/com/scg/stop/proposal/repository/ProposalRepositoryCustomImpl.java new file mode 100644 index 00000000..cff7d9f6 --- /dev/null +++ b/src/main/java/com/scg/stop/proposal/repository/ProposalRepositoryCustomImpl.java @@ -0,0 +1,49 @@ +package com.scg.stop.proposal.repository; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.scg.stop.proposal.domain.Proposal; +import com.scg.stop.proposal.domain.QProposal; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +@RequiredArgsConstructor +public class ProposalRepositoryCustomImpl implements ProposalRepositoryCustom { + + private final JPAQueryFactory queryFactory; + @Override + public Page filterProposals(String scope, String term, Pageable pageable) { + QProposal proposal = QProposal.proposal; + + BooleanExpression predicate = null; + if (scope != null && term != null) { + predicate = switch (scope) { + case "author" -> proposal.user.name.contains(term); + case "title" -> proposal.title.contains(term); + case "both" -> proposal.title.contains(term).or(proposal.content.contains(term)).or(proposal.user.name.contains(term)); + case "content" -> proposal.content.contains(term); + default -> throw new IllegalArgumentException("Invalid scope"); + }; + } + + //TODO: offset 기반 페이징 수정 + List content = queryFactory.selectFrom(proposal) + .where(predicate) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + long total = Optional.ofNullable( + queryFactory.select(proposal.count()) + .from(proposal) + .where(predicate) + .fetchOne() + ).orElse(0L); + + return new PageImpl<>(content, pageable, total); + } +} diff --git a/src/main/java/com/scg/stop/proposal/service/ProposalService.java b/src/main/java/com/scg/stop/proposal/service/ProposalService.java new file mode 100644 index 00000000..9921b2a4 --- /dev/null +++ b/src/main/java/com/scg/stop/proposal/service/ProposalService.java @@ -0,0 +1,197 @@ +package com.scg.stop.proposal.service; + +import static com.scg.stop.proposal.domain.Proposal.convertProjectTypesToString; +import static com.scg.stop.proposal.domain.Proposal.convertStringToProjectTypes; + +import com.scg.stop.file.domain.File; +import com.scg.stop.file.dto.response.FileResponse; +import com.scg.stop.file.repository.FileRepository; +import com.scg.stop.global.exception.BadRequestException; +import com.scg.stop.global.exception.ExceptionCode; +import com.scg.stop.global.infrastructure.EmailService; +import com.scg.stop.proposal.domain.Proposal; +import com.scg.stop.proposal.domain.ProposalReply; +import com.scg.stop.proposal.domain.request.CreateProposalRequest; +import com.scg.stop.proposal.domain.request.ProposalReplyRequest; +import com.scg.stop.proposal.domain.response.ProposalDetailResponse; +import com.scg.stop.proposal.domain.response.ProposalReplyResponse; +import com.scg.stop.proposal.domain.response.ProposalResponse; +import com.scg.stop.proposal.repository.ProposalReplyRepository; +import com.scg.stop.proposal.repository.ProposalRepository; +import com.scg.stop.user.domain.User; +import java.util.Collection; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ProposalService { + + private final ProposalRepository proposalRepository; + private final ProposalReplyRepository proposalReplyRepository; + private final EmailService emailService; + private final FileRepository fileRepository; + @Transactional(readOnly = true) + public Page getProposalList(String scope, String term, Pageable pageable, User requestUser) { + Page proposals = proposalRepository.filterProposals(scope, term, pageable); + return proposals + .map(proposal -> + ProposalResponse.of( + proposal.getId(), + proposal.getDisplayTitle(requestUser), + proposal.getDisplayUser(requestUser), + proposal.getCreatedAt() + ) + ); + } + + @Transactional(readOnly = true) + public ProposalDetailResponse getProposalDetail(Long proposalId, User requestUser) { + Proposal proposal = proposalRepository.findByIdWithFiles(proposalId) + .orElseThrow(()-> new BadRequestException(ExceptionCode.NOT_FOUND_PROPOSAL)); + if (!proposal.isAuthorized(requestUser)) throw new BadRequestException(ExceptionCode.NOT_AUTHORIZED); + return ProposalDetailResponse.of( + proposal.getId(), + proposal.getUser().getName(), + proposal.getEmail(), + proposal.getWebSite(), + proposal.getTitle(), + convertStringToProjectTypes(proposal.getProjectTypes()), + proposal.getContent(), + proposal.getProposalReply() != null, + proposal.getFiles().stream() + .map(FileResponse::from) + .toList() + ); + } + + @Transactional + public ProposalDetailResponse createProposal(User user, CreateProposalRequest proposalCreateRequest) { + List files = fileRepository.findByIdIn(proposalCreateRequest.getFileIds()); + System.out.println(files.get(0).getId()); + Proposal proposal = Proposal.createProposal( + user, + proposalCreateRequest.getTitle(), + convertProjectTypesToString(proposalCreateRequest.getProjectTypes()), + proposalCreateRequest.getEmail(), + proposalCreateRequest.getWebSite(), + proposalCreateRequest.getContent(), + proposalCreateRequest.getIsVisible(), + proposalCreateRequest.getIsAnonymous(), + files + ); + System.out.println(proposal.getFiles().get(0).getId()); + proposalRepository.save(proposal); + //TODO: 이메일 형식 정하기 & 과제 제안메일은 어드민 이메일로만 보내면 되는지? + emailService.sendEmail(proposal.getEmail(), proposal.getTitle(), proposal.getContent()); + return ProposalDetailResponse.of( + proposal.getId(), + proposal.getUser().getName(), + proposal.getEmail(), + proposal.getWebSite(), + proposal.getTitle(), + convertStringToProjectTypes(proposal.getProjectTypes()), + proposal.getContent(), + proposal.getProposalReply() != null, + proposal.getFiles().stream() + .map(FileResponse::from) + .toList() + ); + } + + @Transactional + public ProposalDetailResponse updateProposal(Long proposalId, CreateProposalRequest proposalUpdateRequest, User requestUser) { + Proposal proposal = proposalRepository.findById(proposalId) + .orElseThrow(() -> new BadRequestException(ExceptionCode.NOT_FOUND_PROPOSAL)); + if (!proposal.isAuthorized(requestUser)) throw new BadRequestException(ExceptionCode.NOT_AUTHORIZED); + + List files = fileRepository.findByIdIn(proposalUpdateRequest.getFileIds()); + + proposal.update( + proposalUpdateRequest.getTitle(), + convertProjectTypesToString(proposalUpdateRequest.getProjectTypes()), + proposalUpdateRequest.getEmail(), + proposalUpdateRequest.getWebSite(), + proposalUpdateRequest.getContent(), + proposalUpdateRequest.getIsVisible(), + proposalUpdateRequest.getIsAnonymous(), + files + ); +// emailService.sendEmail(); + return ProposalDetailResponse.of( + proposal.getId(), + proposal.getUser().getName(), + proposal.getEmail(), + proposal.getWebSite(), + proposal.getTitle(), + convertStringToProjectTypes(proposal.getProjectTypes()), + proposal.getContent(), + proposal.getProposalReply() != null, + proposal.getFiles().stream(). + map(FileResponse::from) + .toList() + ); + } + + @Transactional + public ProposalReplyResponse createProposalReply(Long proposalId, + ProposalReplyRequest proposalReplyCreateRequest) { + Proposal proposal = proposalRepository.findById(proposalId) + .orElseThrow(() -> new BadRequestException(ExceptionCode.NOT_FOUND_PROPOSAL)); + ProposalReply proposalReply = ProposalReply.createProposalReply(proposalReplyCreateRequest.getTitle(), + proposalReplyCreateRequest.getContent(), + proposal); + proposalReplyRepository.save(proposalReply); + return ProposalReplyResponse.of(proposalReply.getId(), proposalReply.getTitle(), proposalReply.getContent()); + } + + @Transactional + public ProposalReplyResponse updateProposalReply(Long proposalReplyId, + ProposalReplyRequest proposalReplyUpdateRequest) { + ProposalReply proposalReply = proposalReplyRepository.findById(proposalReplyId) + .orElseThrow(() -> new BadRequestException(ExceptionCode.NOT_FOUND_PROPOSALREPLY)); + + proposalReply.update(proposalReplyUpdateRequest.getTitle(), proposalReplyUpdateRequest.getContent()); + return ProposalReplyResponse.of(proposalReply.getId(), proposalReply.getTitle(), proposalReply.getContent()); + } + + @Transactional + public void deleteProposalReply(Long proposalReplyId) { + ProposalReply proposalReply = proposalReplyRepository.findById(proposalReplyId) + .orElseThrow(() -> new BadRequestException(ExceptionCode.NOT_FOUND_PROPOSALREPLY)); + + proposalReplyRepository.delete(proposalReply); + } + + @Transactional + public void deleteProposal(Long proposalId, User requestUser) { + Proposal proposal = proposalRepository.findById(proposalId) + .orElseThrow(() -> new BadRequestException(ExceptionCode.NOT_FOUND_PROPOSAL)); + if (!proposal.isAuthorized(requestUser)) throw new BadRequestException(ExceptionCode.NOT_AUTHORIZED); + proposalRepository.delete(proposal); + } + + //TODO: 답변이 복수일경우 +// @Transactional(readOnly = true) +// public List getProposalReplies(Long proposalId) { +// Proposal proposal = proposalRepository.findById(proposalId) +// .orElseThrow(() -> new BadRequestException(ExceptionCode.NOT_FOUND_PROPOSAL)); +// return proposalReplyRepository.findByProposal(proposal) +// .stream() +// .map((proposalReply) -> ProposalReplyResponse.of(proposalReply.getId(), proposalReply.getTitle(), proposalReply.getContent())) +// .collect(Collectors.toList()); +// } + + @Transactional(readOnly = true) + public ProposalReplyResponse getProposalReply(Long proposalId, User requestUser) { + Proposal proposal = proposalRepository.findById(proposalId) + .orElseThrow(() -> new BadRequestException(ExceptionCode.NOT_FOUND_PROPOSAL)); + ProposalReply proposalReply = proposalReplyRepository.findByProposal(proposal); + if (!proposalReply.isAuthorized(requestUser)) throw new BadRequestException(ExceptionCode.NOT_AUTHORIZED); + return ProposalReplyResponse.of(proposalReply.getId(), proposalReply.getTitle(), proposalReply.getContent()); + } +} diff --git a/src/main/java/com/scg/stop/user/controller/ApplicationController.java b/src/main/java/com/scg/stop/user/controller/ApplicationController.java new file mode 100644 index 00000000..e8cbab68 --- /dev/null +++ b/src/main/java/com/scg/stop/user/controller/ApplicationController.java @@ -0,0 +1,63 @@ +package com.scg.stop.user.controller; + +import com.scg.stop.auth.annotation.AuthUser; +import com.scg.stop.user.domain.AccessType; +import com.scg.stop.user.domain.User; +import com.scg.stop.user.dto.response.ApplicationDetailResponse; +import com.scg.stop.user.dto.response.ApplicationListResponse; +import com.scg.stop.user.service.ApplicationService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/applications") +public class ApplicationController { + + private final ApplicationService applicationService; + + @GetMapping + public ResponseEntity> getApplications( + @PageableDefault(page = 0, size = 10) Pageable pageable, + @AuthUser(accessType = {AccessType.ADMIN}) User user + ) { + Page applications = applicationService.getApplications(pageable); + return ResponseEntity.ok().body(applications); + } + + @GetMapping("/{applicationId}") + public ResponseEntity getApplication( + @PathVariable("applicationId") Long applicationId, + @AuthUser(accessType = {AccessType.ADMIN}) User user + ) { + ApplicationDetailResponse application = applicationService.getApplication(applicationId); + return ResponseEntity.ok().body(application); + } + + @PatchMapping("/{applicationId}") + public ResponseEntity approveApplication( + @PathVariable("applicationId") Long applicationId, + @AuthUser(accessType = {AccessType.ADMIN}) User user + ) { + ApplicationDetailResponse application = applicationService.approveApplication(applicationId); + return ResponseEntity.ok().body(application); + } + + @DeleteMapping("/{applicationId}") + public ResponseEntity rejectApplication( + @PathVariable("applicationId") Long applicationId, + @AuthUser(accessType = {AccessType.ADMIN}) User user + ) { + applicationService.rejectApplication(applicationId); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/scg/stop/user/controller/DepartmentController.java b/src/main/java/com/scg/stop/user/controller/DepartmentController.java new file mode 100644 index 00000000..f595e94e --- /dev/null +++ b/src/main/java/com/scg/stop/user/controller/DepartmentController.java @@ -0,0 +1,25 @@ +package com.scg.stop.user.controller; + +import com.scg.stop.user.dto.response.DepartmentResponse; +import com.scg.stop.user.service.DepartmentService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/departments") +public class DepartmentController { + + private final DepartmentService departmentService; + + @GetMapping + public ResponseEntity> getDepartments() { + List departments = departmentService.getDepartments(); + return ResponseEntity.ok(departments); + } +} \ No newline at end of file diff --git a/src/main/java/com/scg/stop/user/controller/UserController.java b/src/main/java/com/scg/stop/user/controller/UserController.java new file mode 100644 index 00000000..55edb67d --- /dev/null +++ b/src/main/java/com/scg/stop/user/controller/UserController.java @@ -0,0 +1,79 @@ +package com.scg.stop.user.controller; + +import com.scg.stop.auth.annotation.AuthUser; +import com.scg.stop.project.dto.response.ProjectResponse; +import com.scg.stop.user.domain.AccessType; +import com.scg.stop.user.domain.User; +import com.scg.stop.user.dto.request.UserUpdateRequest; +import com.scg.stop.user.dto.response.FavoriteResponse; +import com.scg.stop.user.dto.response.UserInquiryResponse; +import com.scg.stop.user.dto.response.UserProposalResponse; +import com.scg.stop.user.dto.response.UserResponse; +import com.scg.stop.user.service.UserService; +import com.scg.stop.video.dto.response.JobInterviewUserResponse; +import com.scg.stop.video.dto.response.TalkUserResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/users") +public class UserController { + + private final UserService userService; + + @GetMapping("/me") + public ResponseEntity getMe(@AuthUser(accessType = {AccessType.ALL}) User user) { + UserResponse userResponse = userService.getMe(user); + return ResponseEntity.ok(userResponse); + } + + @PutMapping("/me") + public ResponseEntity updateMe( + @AuthUser(accessType = {AccessType.ALL}) User user, + @RequestBody @Valid UserUpdateRequest request + ) { + UserResponse updatedUserResponse = userService.updateMe(user, request); + return ResponseEntity.ok(updatedUserResponse); + } + + @DeleteMapping("/me") + public ResponseEntity deleteMe(@AuthUser(accessType = {AccessType.ALL}) User user) { + userService.deleteMe(user); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/inquiries") + public ResponseEntity> getUserInquiries(@AuthUser(accessType = {AccessType.ALL}) User user) { + List inquiries = userService.getUserInquiries(user); + return ResponseEntity.ok(inquiries); + } + + @GetMapping("/proposals") + public ResponseEntity> getUserProposals(@AuthUser(accessType = {AccessType.ALL}) User user) { + List proposals = userService.getUserProposals(user); + return ResponseEntity.ok(proposals); + } + + @GetMapping("/favorites/projects") + public ResponseEntity> getUserFavoriteProjects(@AuthUser(accessType = {AccessType.ALL}) User user) { + List userFavoriteProjects = userService.getUserFavoriteProjects(user); + return ResponseEntity.ok(userFavoriteProjects); + } + + @GetMapping("/favorites/talks") + public ResponseEntity> getUserFavoriteTalks(@AuthUser(accessType = {AccessType.ALL}) User user) { + List userFavoriteTalks = userService.getUserFavoriteTalks(user); + return ResponseEntity.ok(userFavoriteTalks); + } + + @GetMapping("/favorites/jobInterviews") + public ResponseEntity> getUserFavoriteInterviews(@AuthUser(accessType = {AccessType.ALL}) User user) { + List userFavoriteInterviews = userService.getUserFavoriteInterviews(user); + return ResponseEntity.ok(userFavoriteInterviews); + } +} diff --git a/src/main/java/com/scg/stop/user/domain/AccessType.java b/src/main/java/com/scg/stop/user/domain/AccessType.java new file mode 100644 index 00000000..fd67e47f --- /dev/null +++ b/src/main/java/com/scg/stop/user/domain/AccessType.java @@ -0,0 +1,13 @@ +package com.scg.stop.user.domain; + +import lombok.Getter; + +@Getter +public enum AccessType { + ADMIN, + PROFESSOR, + STUDENT, + COMPANY, + ALL, + OPTIONAL +} diff --git a/src/main/java/com/scg/stop/user/domain/Application.java b/src/main/java/com/scg/stop/user/domain/Application.java new file mode 100644 index 00000000..e18ff3e2 --- /dev/null +++ b/src/main/java/com/scg/stop/user/domain/Application.java @@ -0,0 +1,63 @@ +package com.scg.stop.user.domain; + +import static jakarta.persistence.EnumType.STRING; +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import com.scg.stop.global.domain.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = PROTECTED) +@AllArgsConstructor +public class Application extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + private String division; //소속 + + private String position; // 직책 + + @Column(nullable = false) + @Enumerated(value = STRING) + ApplicationStatus status = ApplicationStatus.INACTIVE; + + @OneToOne(fetch = LAZY) + @JoinColumn(name = "user_id") + private User user; + + public Application(String division, String position, User user) { + this.division = division; + this.position = position; + this.user = user; + } + + public void activate() { + status = ApplicationStatus.ACTIVE; + } + + public void reject() { + status = ApplicationStatus.REJECTED; + } + + public void updateDivision(String newDivision) { + this.division = newDivision; + } + + public void updatePosition(String newPosition) { + this.position = newPosition; + } +} diff --git a/src/main/java/com/scg/stop/user/domain/ApplicationStatus.java b/src/main/java/com/scg/stop/user/domain/ApplicationStatus.java new file mode 100644 index 00000000..9c017e82 --- /dev/null +++ b/src/main/java/com/scg/stop/user/domain/ApplicationStatus.java @@ -0,0 +1,8 @@ +package com.scg.stop.user.domain; + +public enum ApplicationStatus { + + INACTIVE, // 승인 전 + ACTIVE, // 승인 됨 + REJECTED // 승인 거절 됨 +} diff --git a/src/main/java/com/scg/stop/user/domain/Department.java b/src/main/java/com/scg/stop/user/domain/Department.java new file mode 100644 index 00000000..6a32401e --- /dev/null +++ b/src/main/java/com/scg/stop/user/domain/Department.java @@ -0,0 +1,36 @@ +package com.scg.stop.user.domain; + +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import com.scg.stop.global.domain.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import java.util.ArrayList; +import java.util.List; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = PROTECTED) +public class Department extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String name; + + @OneToMany(fetch = LAZY, mappedBy = "department") + private List students = new ArrayList<>(); + + public void updateName(String newName) { + this.name = newName; + } +} diff --git a/src/main/java/com/scg/stop/user/domain/FavoriteType.java b/src/main/java/com/scg/stop/user/domain/FavoriteType.java new file mode 100644 index 00000000..ba8fba1a --- /dev/null +++ b/src/main/java/com/scg/stop/user/domain/FavoriteType.java @@ -0,0 +1,7 @@ +package com.scg.stop.user.domain; + +public enum FavoriteType { + PROJECT, // 프로젝트 + TALK, // 대담영상 + JOBINTERVIEW // 잡페어영상 +} diff --git a/src/main/java/com/scg/stop/user/domain/Student.java b/src/main/java/com/scg/stop/user/domain/Student.java new file mode 100644 index 00000000..720fe345 --- /dev/null +++ b/src/main/java/com/scg/stop/user/domain/Student.java @@ -0,0 +1,61 @@ +package com.scg.stop.user.domain; + +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import com.scg.stop.global.domain.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = PROTECTED) +public class Student extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @Column(nullable = false) + private String studentNumber; + + @OneToOne(fetch = LAZY) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "dept_id") + private Department department; + + @Builder + private Student(String studentNumber, User user, Department department) { + this.studentNumber = studentNumber; + this.user = user; + this.department = department; + } + + public static Student of(String studentNumber, User user, Department department) { + return new Student().builder() + .studentNumber(studentNumber) + .user(user) + .department(department) + .build(); + } + + public void updateStudentNumber(String newStudentNumber) { + this.studentNumber = newStudentNumber; + } + + public void updateDepartment(Department newDepartment) { + this.department = newDepartment; + } +} diff --git a/src/main/java/com/scg/stop/user/domain/User.java b/src/main/java/com/scg/stop/user/domain/User.java new file mode 100644 index 00000000..5561a202 --- /dev/null +++ b/src/main/java/com/scg/stop/user/domain/User.java @@ -0,0 +1,142 @@ +package com.scg.stop.user.domain; + +import static jakarta.persistence.EnumType.*; +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.*; +import static lombok.AccessLevel.*; + +import com.scg.stop.project.domain.Comment; +import com.scg.stop.project.domain.FavoriteProject; +import com.scg.stop.project.domain.Inquiry; +import com.scg.stop.project.domain.Likes; +import com.scg.stop.proposal.domain.Proposal; +import com.scg.stop.video.domain.FavoriteVideo; +import com.scg.stop.video.domain.UserQuiz; +import com.scg.stop.global.domain.BaseTimeEntity; +import jakarta.persistence.*; + +import java.util.ArrayList; +import java.util.List; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = PROTECTED) +public class User extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @Column + private String name; + + @Column + private String phone; + + @Column + private String email; + + @Column(nullable = false) + private String socialLoginId; + + @Column(nullable = false) + @Enumerated(value = STRING) + private UserType userType; + + private String signupSource; + + @OneToOne(fetch = LAZY, mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private Application application; + + @OneToOne(fetch = LAZY, mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private Student studentInfo; + + @OneToMany(fetch = LAZY, mappedBy = "user") + private List proposals = new ArrayList<>(); + + @OneToMany(fetch = LAZY, mappedBy = "user", cascade = CascadeType.REMOVE, orphanRemoval = true) + private List userQuizzes = new ArrayList<>(); + + @OneToMany(fetch = LAZY, mappedBy = "user", cascade = CascadeType.REMOVE, orphanRemoval = true) + private List favoriteVideos = new ArrayList<>(); + + @OneToMany(fetch = LAZY, mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private List likes = new ArrayList<>(); + + @OneToMany(fetch = LAZY, mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private List favoriteProjects = new ArrayList<>(); + + @OneToMany(fetch = LAZY, mappedBy = "user") + private List comments = new ArrayList<>(); + + @OneToMany(fetch = LAZY, mappedBy = "user") + private List inquiries = new ArrayList<>(); + + public void activateUser() { + if (userType == UserType.INACTIVE_COMPANY) { + userType = UserType.COMPANY; + } else if (userType == UserType.INACTIVE_PROFESSOR) { + userType = UserType.PROFESSOR; + } + application.activate(); + } + + public User(String socialLoginId) { + this.userType = UserType.TEMP; + this.socialLoginId = socialLoginId; + } + + public void register(String name, String email, String phone, UserType userType, String signupSource ) { + this.name = name; + this.email = email; + this.phone = phone; + this.userType = userType; + this.signupSource = signupSource; + } + + public void addLikes(Likes likes) { + this.likes.add(likes); + } + + public void removeLikes(Likes likes) { + this.likes.remove(likes); + } + + public void addFavoriteProject(FavoriteProject favoriteProject) { + this.favoriteProjects.add(favoriteProject); + } + + public void removeFavoriteProject(FavoriteProject favoriteProject) { + this.favoriteProjects.remove(favoriteProject); + } + + public void updateStudentInfo(Student studentInfo) { + this.studentInfo = studentInfo; + } + + public void updateApplication(Application application) { + this.application = application; + } + + @PreRemove + private void preRemove() { + proposals.forEach(proposal -> proposal.setUser(null)); + comments.forEach(comment -> comment.setUser(null)); + inquiries.forEach(inquiry -> inquiry.setUser(null)); + } + + public void updateName(String newName) { + this.name = newName; + } + + public void updatePhone(String newPhone) { + this.phone = newPhone; + } + + public void updateEmail(String newEmail) { + this.email = newEmail; + } + +} diff --git a/src/main/java/com/scg/stop/user/domain/UserType.java b/src/main/java/com/scg/stop/user/domain/UserType.java new file mode 100644 index 00000000..e320566c --- /dev/null +++ b/src/main/java/com/scg/stop/user/domain/UserType.java @@ -0,0 +1,13 @@ +package com.scg.stop.user.domain; + +public enum UserType { + TEMP, + STUDENT, + PROFESSOR, + COMPANY, + ADMIN, + INACTIVE_PROFESSOR, + INACTIVE_COMPANY, + OTHERS, + EXTERNAL +} diff --git a/src/main/java/com/scg/stop/user/dto/request/UserUpdateRequest.java b/src/main/java/com/scg/stop/user/dto/request/UserUpdateRequest.java new file mode 100644 index 00000000..caeee8de --- /dev/null +++ b/src/main/java/com/scg/stop/user/dto/request/UserUpdateRequest.java @@ -0,0 +1,41 @@ +package com.scg.stop.user.dto.request; + +import com.scg.stop.global.exception.BadRequestException; +import com.scg.stop.global.exception.ExceptionCode; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import static io.micrometer.common.util.StringUtils.isBlank; +import static lombok.AccessLevel.PRIVATE; + +@Getter +@NoArgsConstructor(access = PRIVATE) +@AllArgsConstructor +public class UserUpdateRequest { + + @NotBlank(message = "이름을 입력해주세요.") + private String name; + + @NotBlank(message = "전화번호를 입력해주세요.") + private String phoneNumber; + + @NotBlank(message = "이메일을 입력해주세요.") + private String email; + + // UserType.PROFESSOR, UserType.COMPANY + private String division; + private String position; + + // UserType.STUDENT + private String studentNumber; + private String department; + + public void validateStudentInfo() { + if (isBlank(this.studentNumber) || isBlank(this.department)) { + throw new BadRequestException(ExceptionCode.INVALID_STUDENTINFO); + } + } + +} diff --git a/src/main/java/com/scg/stop/user/dto/response/ApplicationDetailResponse.java b/src/main/java/com/scg/stop/user/dto/response/ApplicationDetailResponse.java new file mode 100644 index 00000000..c155e6d1 --- /dev/null +++ b/src/main/java/com/scg/stop/user/dto/response/ApplicationDetailResponse.java @@ -0,0 +1,40 @@ +package com.scg.stop.user.dto.response; + +import static lombok.AccessLevel.PRIVATE; + +import com.scg.stop.user.domain.Application; +import com.scg.stop.user.domain.UserType; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = PRIVATE) +@AllArgsConstructor +public class ApplicationDetailResponse { + + private Long id; + private String name; + private String phone; + private String email; + private String division; + private String position; + private UserType userType; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public static ApplicationDetailResponse from(Application application) { + return new ApplicationDetailResponse( + application.getId(), + application.getUser().getName(), + application.getUser().getPhone(), + application.getUser().getEmail(), + application.getDivision(), + application.getPosition(), + application.getUser().getUserType(), + application.getCreatedAt(), + application.getUpdatedAt() + ); + } +} diff --git a/src/main/java/com/scg/stop/user/dto/response/ApplicationListResponse.java b/src/main/java/com/scg/stop/user/dto/response/ApplicationListResponse.java new file mode 100644 index 00000000..270a1a3d --- /dev/null +++ b/src/main/java/com/scg/stop/user/dto/response/ApplicationListResponse.java @@ -0,0 +1,36 @@ +package com.scg.stop.user.dto.response; + +import static lombok.AccessLevel.PRIVATE; + +import com.scg.stop.user.domain.Application; +import com.scg.stop.user.domain.UserType; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = PRIVATE) +@AllArgsConstructor +public class ApplicationListResponse { + + private Long id; + private String name; + private String division; + private String position; + private UserType userType; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public static ApplicationListResponse from(Application application) { + return new ApplicationListResponse( + application.getId(), + application.getUser().getName(), + application.getDivision(), + application.getPosition(), + application.getUser().getUserType(), + application.getCreatedAt(), + application.getUpdatedAt() + ); + } +} diff --git a/src/main/java/com/scg/stop/user/dto/response/DepartmentResponse.java b/src/main/java/com/scg/stop/user/dto/response/DepartmentResponse.java new file mode 100644 index 00000000..e57eb400 --- /dev/null +++ b/src/main/java/com/scg/stop/user/dto/response/DepartmentResponse.java @@ -0,0 +1,20 @@ +package com.scg.stop.user.dto.response; + +import com.scg.stop.user.domain.Department; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class DepartmentResponse { + + private Long id; + private String name; + + public static DepartmentResponse from(Department department) { + return new DepartmentResponse( + department.getId(), + department.getName() + ); + } +} diff --git a/src/main/java/com/scg/stop/user/dto/response/FavoriteResponse.java b/src/main/java/com/scg/stop/user/dto/response/FavoriteResponse.java new file mode 100644 index 00000000..ba062e82 --- /dev/null +++ b/src/main/java/com/scg/stop/user/dto/response/FavoriteResponse.java @@ -0,0 +1,17 @@ +package com.scg.stop.user.dto.response; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class FavoriteResponse { + private Long id; + private String title; + private String youtubeId; + + public static FavoriteResponse of(Long id, String title, String youtubeId) { + return new FavoriteResponse(id, title, youtubeId); + } +} diff --git a/src/main/java/com/scg/stop/user/dto/response/UserInquiryResponse.java b/src/main/java/com/scg/stop/user/dto/response/UserInquiryResponse.java new file mode 100644 index 00000000..8110800a --- /dev/null +++ b/src/main/java/com/scg/stop/user/dto/response/UserInquiryResponse.java @@ -0,0 +1,29 @@ +package com.scg.stop.user.dto.response; + +import com.scg.stop.project.domain.Inquiry; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@AllArgsConstructor +public class UserInquiryResponse { + + private Long id; + private String title; + private Long projectId; + private LocalDateTime createdDate; + private boolean hasReply; + + public static UserInquiryResponse from(Inquiry inquiry) { + return new UserInquiryResponse( + inquiry.getId(), + inquiry.getTitle(), + inquiry.getProject() != null ? inquiry.getProject().getId() : null, + inquiry.getCreatedAt(), + inquiry.getReply() != null + ); + } +} + diff --git a/src/main/java/com/scg/stop/user/dto/response/UserProposalResponse.java b/src/main/java/com/scg/stop/user/dto/response/UserProposalResponse.java new file mode 100644 index 00000000..f374b950 --- /dev/null +++ b/src/main/java/com/scg/stop/user/dto/response/UserProposalResponse.java @@ -0,0 +1,26 @@ +package com.scg.stop.user.dto.response; + +import com.scg.stop.proposal.domain.Proposal; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@AllArgsConstructor +public class UserProposalResponse { + + private Long id; + private String title; + private LocalDateTime createdDate; + private boolean hasReply; + + public static UserProposalResponse from(Proposal proposal) { + return new UserProposalResponse( + proposal.getId(), + proposal.getTitle(), + proposal.getCreatedAt(), + proposal.getProposalReply() != null + ); + } +} diff --git a/src/main/java/com/scg/stop/user/dto/response/UserResponse.java b/src/main/java/com/scg/stop/user/dto/response/UserResponse.java new file mode 100644 index 00000000..4df8ce9a --- /dev/null +++ b/src/main/java/com/scg/stop/user/dto/response/UserResponse.java @@ -0,0 +1,48 @@ +package com.scg.stop.user.dto.response; + +import static lombok.AccessLevel.PRIVATE; + +import com.scg.stop.user.domain.Application; +import com.scg.stop.user.domain.Student; +import com.scg.stop.user.domain.User; +import com.scg.stop.user.domain.UserType; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = PRIVATE) +@AllArgsConstructor +@Builder +public class UserResponse { + private Long id; + private String name; + private String phone; + private String email; + private UserType userType; + private String division; // UserType.PROFESSOR, UserType.COMPANY + private String position; // UserType.PROFESSOR, UserType.COMPANY + private String studentNumber; // UserType.STUDENT + private String departmentName; // UserType.STUDENT + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public static UserResponse of(User user, String division, String position, String studentNumber, String departmentName) { + return new UserResponse( + user.getId(), + user.getName(), + user.getPhone(), + user.getEmail(), + user.getUserType(), + division, + position, + studentNumber, + departmentName, + user.getCreatedAt(), + user.getUpdatedAt() + ); + } +} diff --git a/src/main/java/com/scg/stop/user/repository/ApplicationRepository.java b/src/main/java/com/scg/stop/user/repository/ApplicationRepository.java new file mode 100644 index 00000000..d03f26cc --- /dev/null +++ b/src/main/java/com/scg/stop/user/repository/ApplicationRepository.java @@ -0,0 +1,16 @@ +package com.scg.stop.user.repository; + +import com.scg.stop.user.domain.Application; +import com.scg.stop.user.domain.ApplicationStatus; +import com.scg.stop.user.domain.UserType; +import java.util.List; +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; + +public interface ApplicationRepository extends JpaRepository { + + Page findByStatus(ApplicationStatus status, Pageable pageable); +} diff --git a/src/main/java/com/scg/stop/user/repository/DepartmentRepository.java b/src/main/java/com/scg/stop/user/repository/DepartmentRepository.java new file mode 100644 index 00000000..e8d00157 --- /dev/null +++ b/src/main/java/com/scg/stop/user/repository/DepartmentRepository.java @@ -0,0 +1,10 @@ +package com.scg.stop.user.repository; + +import com.scg.stop.user.domain.Department; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface DepartmentRepository extends JpaRepository { + + public Optional findByName(String name); +} diff --git a/src/main/java/com/scg/stop/user/repository/StudentRepository.java b/src/main/java/com/scg/stop/user/repository/StudentRepository.java new file mode 100644 index 00000000..07f83737 --- /dev/null +++ b/src/main/java/com/scg/stop/user/repository/StudentRepository.java @@ -0,0 +1,7 @@ +package com.scg.stop.user.repository; + +import com.scg.stop.user.domain.Student; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface StudentRepository extends JpaRepository { +} diff --git a/src/main/java/com/scg/stop/user/repository/UserRepository.java b/src/main/java/com/scg/stop/user/repository/UserRepository.java new file mode 100644 index 00000000..4759b0c5 --- /dev/null +++ b/src/main/java/com/scg/stop/user/repository/UserRepository.java @@ -0,0 +1,9 @@ +package com.scg.stop.user.repository; + +import com.scg.stop.user.domain.User; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository { + public Optional findBySocialLoginId(String socialLoginId); +} diff --git a/src/main/java/com/scg/stop/user/service/ApplicationService.java b/src/main/java/com/scg/stop/user/service/ApplicationService.java new file mode 100644 index 00000000..5c03eb62 --- /dev/null +++ b/src/main/java/com/scg/stop/user/service/ApplicationService.java @@ -0,0 +1,59 @@ +package com.scg.stop.user.service; + +import static com.scg.stop.global.exception.ExceptionCode.ALREADY_VERIFIED_USER; +import static com.scg.stop.global.exception.ExceptionCode.NOT_FOUND_APPLICATION_ID; + +import com.scg.stop.user.domain.Application; +import com.scg.stop.user.domain.ApplicationStatus; +import com.scg.stop.user.domain.User; +import com.scg.stop.user.dto.response.ApplicationDetailResponse; +import com.scg.stop.user.dto.response.ApplicationListResponse; +import com.scg.stop.user.repository.ApplicationRepository; +import com.scg.stop.global.exception.BadRequestException; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class ApplicationService { + + private final ApplicationRepository applicationRepository; + + @Transactional(readOnly = true) + public Page getApplications(Pageable pageable) { + Page filteredApplications = applicationRepository.findByStatus(ApplicationStatus.INACTIVE, pageable); + return filteredApplications.map(ApplicationListResponse::from); + } + + @Transactional(readOnly = true) + public ApplicationDetailResponse getApplication(Long applicationId) { + Application application = applicationRepository.findById(applicationId) + .orElseThrow(() -> new BadRequestException(NOT_FOUND_APPLICATION_ID)); + if(application.getStatus() == ApplicationStatus.ACTIVE) { + throw new BadRequestException(ALREADY_VERIFIED_USER); + } + return ApplicationDetailResponse.from(application); + } + + public ApplicationDetailResponse approveApplication(Long applicationId) { + Application application = applicationRepository.findById(applicationId) + .orElseThrow(() -> new BadRequestException(NOT_FOUND_APPLICATION_ID)); + if(application.getStatus() == ApplicationStatus.ACTIVE) { + throw new BadRequestException(ALREADY_VERIFIED_USER); + } + + User user = application.getUser(); + user.activateUser(); + return ApplicationDetailResponse.from(application); + } + + public void rejectApplication(Long applicationId) { + Application application = applicationRepository.findById(applicationId) + .orElseThrow(() -> new BadRequestException(NOT_FOUND_APPLICATION_ID)); + application.reject(); + } +} diff --git a/src/main/java/com/scg/stop/user/service/DepartmentService.java b/src/main/java/com/scg/stop/user/service/DepartmentService.java new file mode 100644 index 00000000..a75b03b7 --- /dev/null +++ b/src/main/java/com/scg/stop/user/service/DepartmentService.java @@ -0,0 +1,26 @@ +package com.scg.stop.user.service; + +import com.scg.stop.user.dto.response.DepartmentResponse; +import com.scg.stop.user.repository.DepartmentRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional +public class DepartmentService { + + private final DepartmentRepository departmentRepository; + + @Transactional(readOnly = true) + public List getDepartments() { + return departmentRepository.findAll() + .stream() + .map(DepartmentResponse::from) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/src/main/java/com/scg/stop/user/service/UserService.java b/src/main/java/com/scg/stop/user/service/UserService.java new file mode 100644 index 00000000..1b7bc551 --- /dev/null +++ b/src/main/java/com/scg/stop/user/service/UserService.java @@ -0,0 +1,163 @@ +package com.scg.stop.user.service; + +import com.scg.stop.global.exception.BadRequestException; +import com.scg.stop.global.exception.ExceptionCode; +import com.scg.stop.project.domain.Inquiry; +import com.scg.stop.project.domain.Project; +import com.scg.stop.project.dto.response.ProjectResponse; +import com.scg.stop.project.repository.FavoriteProjectRepository; +import com.scg.stop.project.repository.InquiryRepository; +import com.scg.stop.proposal.domain.Proposal; +import com.scg.stop.proposal.repository.ProposalRepository; +import com.scg.stop.user.domain.Department; +import com.scg.stop.user.domain.Student; +import com.scg.stop.user.domain.User; +import com.scg.stop.user.domain.UserType; +import com.scg.stop.user.dto.request.UserUpdateRequest; +import com.scg.stop.user.dto.response.UserInquiryResponse; +import com.scg.stop.user.dto.response.UserProposalResponse; +import com.scg.stop.user.dto.response.UserResponse; +import com.scg.stop.user.repository.DepartmentRepository; +import com.scg.stop.user.repository.UserRepository; +import com.scg.stop.video.domain.JobInterview; +import com.scg.stop.video.domain.Talk; +import com.scg.stop.video.dto.response.JobInterviewUserResponse; +import com.scg.stop.video.dto.response.TalkUserResponse; +import com.scg.stop.video.repository.FavoriteVideoRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import static io.micrometer.common.util.StringUtils.isBlank; +import static java.util.Objects.isNull; + +@Service +@RequiredArgsConstructor +@Transactional +public class UserService { + + private final UserRepository userRepository; + private final DepartmentRepository departmentRepository; + private final InquiryRepository inquiryRepository; + private final ProposalRepository proposalRepository; + private final FavoriteProjectRepository favoriteProjectRepository; + private final FavoriteVideoRepository favoriteVideoRepository; + + @Transactional(readOnly = true) + public UserResponse getMe(User user) { + if (user.getUserType().equals(UserType.STUDENT)) { + Student studentInfo = user.getStudentInfo(); + Department department = departmentRepository.findById(studentInfo.getDepartment().getId()) + .orElseThrow(() -> new BadRequestException(ExceptionCode.NOT_FOUND_DEPARTMENT)); + + return UserResponse.of( + user, + null, + null, + studentInfo.getStudentNumber(), + department.getName() + ); + } else if (Arrays.asList(UserType.INACTIVE_PROFESSOR, UserType.COMPANY, UserType.INACTIVE_COMPANY, UserType.PROFESSOR).contains(user.getUserType())) { + return UserResponse.of( + user, + user.getApplication().getDivision(), + user.getApplication().getPosition(), + null, + null + ); + } else { + return UserResponse.of( + user, + null, + null, + null, + null + ); + } + } + + public UserResponse updateMe(User user, UserUpdateRequest request) { + user.updateName(request.getName()); + user.updatePhone(request.getPhoneNumber()); + user.updateEmail(request.getEmail()); + + if (user.getUserType().equals(UserType.STUDENT)) { + request.validateStudentInfo(); + + Department department = departmentRepository.findByName( + request.getDepartment()) + .orElseThrow(() -> new BadRequestException(ExceptionCode.NOT_FOUND_DEPARTMENT)); + + user.getStudentInfo().updateStudentNumber(request.getStudentNumber()); + user.getStudentInfo().updateDepartment(department); + } else if (Arrays.asList(UserType.INACTIVE_PROFESSOR, UserType.COMPANY, UserType.INACTIVE_COMPANY, UserType.PROFESSOR).contains(user.getUserType())) { + if (!isNull(request.getDivision()) && isBlank(request.getDivision()) || + !isNull(request.getPosition()) && isBlank(request.getPosition())) { + + throw new BadRequestException(ExceptionCode.DIVISION_OR_POSITION_REQUIRED); + } + + user.getApplication().updateDivision(request.getDivision()); + user.getApplication().updatePosition(request.getPosition()); + } + + userRepository.save(user); + + return UserResponse.of( + user, + user.getApplication() != null ? user.getApplication().getDivision() : null, + user.getApplication() != null ? user.getApplication().getPosition() : null, + user.getStudentInfo() != null ? user.getStudentInfo().getStudentNumber() : null, + user.getStudentInfo() != null ? user.getStudentInfo().getDepartment().getName() : null + ); + } + + public void deleteMe(User user) { + userRepository.delete(user); + } + + @Transactional(readOnly = true) + public List getUserInquiries(User user) { + List inquiries = inquiryRepository.findByUser(user); + return inquiries.stream() + .map(UserInquiryResponse::from) + .collect(Collectors.toList()); + } + + @Transactional(readOnly = true) + public List getUserProposals(User user) { + List proposals = proposalRepository.findByUser(user); + return proposals.stream() + .map(UserProposalResponse::from) + .collect(Collectors.toList()); + } + + @Transactional(readOnly = true) + public List getUserFavoriteProjects(User user) { + List projects = favoriteProjectRepository.findAllByUser(user); + return projects.stream() + .map(project -> ProjectResponse.of(user, project)) + .collect(Collectors.toList()); + } + + @Transactional(readOnly = true) + public List getUserFavoriteTalks(User user) { + List talks = favoriteVideoRepository.findTalksByUser(user); + return talks.stream() + .map(talk -> TalkUserResponse.from(talk, true)) + .collect(Collectors.toList()); + } + + @Transactional(readOnly = true) + public List getUserFavoriteInterviews(User user) { + List jobInterviews = favoriteVideoRepository.findJobInterviewsByUser(user); + return jobInterviews.stream() + .map(jobInterview -> JobInterviewUserResponse.from(jobInterview, true)) + .collect(Collectors.toList()); + } + +} diff --git a/src/main/java/com/scg/stop/video/controller/JobInterviewController.java b/src/main/java/com/scg/stop/video/controller/JobInterviewController.java new file mode 100644 index 00000000..3a405240 --- /dev/null +++ b/src/main/java/com/scg/stop/video/controller/JobInterviewController.java @@ -0,0 +1,95 @@ +package com.scg.stop.video.controller; + +import com.scg.stop.auth.annotation.AuthUser; +import com.scg.stop.video.domain.JobInterviewCategory; +import com.scg.stop.video.dto.request.JobInterviewRequest; +import com.scg.stop.video.dto.response.JobInterviewResponse; +import com.scg.stop.video.dto.response.JobInterviewUserResponse; +import com.scg.stop.video.service.FavoriteVideoService; +import com.scg.stop.video.service.JobInterviewService; +import com.scg.stop.user.domain.AccessType; +import com.scg.stop.user.domain.User; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/jobInterviews") +public class JobInterviewController { + private final JobInterviewService jobInterviewService; + private final FavoriteVideoService favoriteVideoService; + + @PostMapping + public ResponseEntity createJobInterview( + @RequestBody @Valid JobInterviewRequest jobInterviewDTO, + @AuthUser(accessType = {AccessType.ADMIN}) User user + ) { + JobInterviewResponse interview = jobInterviewService.createJobInterview(jobInterviewDTO); + return ResponseEntity.status(HttpStatus.CREATED).body(interview); + } + + @GetMapping + public ResponseEntity> getJobInterviews( + @RequestParam(value = "title", required = false) String title, + @RequestParam(value = "year", required = false) Integer year, + @RequestParam(value = "category", required = false) JobInterviewCategory category, + @AuthUser(accessType = {AccessType.OPTIONAL}) User user, + @PageableDefault(page = 0, size = 10) Pageable pageable + ) { + return ResponseEntity.status(HttpStatus.OK).body(jobInterviewService.getJobInterviews(user, year, category, title, pageable)); + } + + @GetMapping("/{jobInterviewId}") + public ResponseEntity getJobInterview( + @PathVariable("jobInterviewId") Long jobInterviewId, + @AuthUser(accessType = {AccessType.OPTIONAL}) User user + ) { + return ResponseEntity.status(HttpStatus.OK).body(jobInterviewService.getJobInterview(jobInterviewId, user)); + } + + @PutMapping("/{jobInterviewId}") + public ResponseEntity updateJobInterview( + @PathVariable("jobInterviewId")Long jobInterviewId, + @RequestBody @Valid JobInterviewRequest jobInterviewDTO, + @AuthUser(accessType = {AccessType.ADMIN}) User user + ) { + return ResponseEntity.status(HttpStatus.OK).body(jobInterviewService.updateJobInterview(jobInterviewId, jobInterviewDTO)); + } + + @DeleteMapping("/{jobInterviewId}") + public ResponseEntity deleteJobInterview( + @PathVariable("jobInterviewId") Long jobInterviewId, + @AuthUser(accessType = {AccessType.ADMIN}) User user + ) { + jobInterviewService.deleteJobInterviewById(jobInterviewId); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + @PostMapping("/{jobInterviewId}/favorite") + public ResponseEntity createJobInterviewFavorite( + @PathVariable("jobInterviewId") Long jobInterviewId, + @AuthUser(accessType = {AccessType.ALL}) User user + ) { + favoriteVideoService.createJobInterviewFavorite(jobInterviewId, user); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @DeleteMapping("/{jobInterviewId}/favorite") + public ResponseEntity deleteJobInterviewFavorite( + @PathVariable("jobInterviewId") Long jobInterviewId, + @AuthUser(accessType = {AccessType.ALL}) User user + ) { + favoriteVideoService.deleteJobInterviewFavorite(jobInterviewId, user); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + +} diff --git a/src/main/java/com/scg/stop/video/controller/QuizController.java b/src/main/java/com/scg/stop/video/controller/QuizController.java new file mode 100644 index 00000000..7fef6bc2 --- /dev/null +++ b/src/main/java/com/scg/stop/video/controller/QuizController.java @@ -0,0 +1,65 @@ +package com.scg.stop.video.controller; + +import com.scg.stop.auth.annotation.AuthUser; +import com.scg.stop.global.excel.Excel; +import com.scg.stop.user.domain.AccessType; +import com.scg.stop.user.domain.User; +import com.scg.stop.video.dto.response.UserQuizResultResponse; +import com.scg.stop.video.service.QuizService; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; + + +@RestController +@RequiredArgsConstructor +@RequestMapping("/quizzes") +public class QuizController { + private final QuizService quizService; + + @GetMapping("/result") + public ResponseEntity> getQuizResults( + @RequestParam(value = "year", required = false) Integer year, + @AuthUser(accessType = {AccessType.ADMIN}) User user, + @PageableDefault(page = 0, size = 10) Pageable pageable + ) { + Page responses = quizService.getQuizResults(year, pageable); + return ResponseEntity.status(HttpStatus.OK).body(responses); + } + + @GetMapping("/result/excel") + public ResponseEntity getQuizResultToExcel( + @RequestParam(value = "year", required = false) Integer year, + @AuthUser(accessType = {AccessType.ADMIN}) User user + ) { + Excel excel = quizService.getQuizResultToExcel(year); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try { + excel.write(baos); + } catch (IOException e) { + throw new RuntimeException(e); + } + + byte[] byteData = baos.toByteArray(); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); + headers.set(HttpHeaders.CONTENT_DISPOSITION, String.format("attachment; filename=%s",excel.getFilename())); + + return ResponseEntity.status(HttpStatus.OK).headers(headers).body(byteData); + + } +} diff --git a/src/main/java/com/scg/stop/video/controller/TalkController.java b/src/main/java/com/scg/stop/video/controller/TalkController.java new file mode 100644 index 00000000..089028b0 --- /dev/null +++ b/src/main/java/com/scg/stop/video/controller/TalkController.java @@ -0,0 +1,123 @@ +package com.scg.stop.video.controller; + +import com.scg.stop.auth.annotation.AuthUser; +import com.scg.stop.video.dto.request.QuizSubmitRequest; +import com.scg.stop.video.dto.request.TalkRequest; +import com.scg.stop.video.dto.response.QuizResponse; +import com.scg.stop.video.dto.response.QuizSubmitResponse; +import com.scg.stop.video.dto.response.TalkResponse; +import com.scg.stop.video.dto.response.TalkUserResponse; +import com.scg.stop.video.service.FavoriteVideoService; +import com.scg.stop.video.service.QuizService; +import com.scg.stop.video.service.TalkService; +import com.scg.stop.user.domain.AccessType; +import com.scg.stop.user.domain.User; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + + +@RestController +@RequiredArgsConstructor +@RequestMapping("/talks") +public class TalkController { + private final TalkService talkService; + private final QuizService quizService; + private final FavoriteVideoService favoriteVideoService; + + @PostMapping + public ResponseEntity createTalk( + @RequestBody @Valid TalkRequest talkRequest, + @AuthUser(accessType = {AccessType.ADMIN}) User user + ) { + TalkResponse response = talkService.createTalk(talkRequest); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @GetMapping + public ResponseEntity> getAllTalks( + @RequestParam(value = "title", required = false) String title, + @RequestParam(value = "year", required = false) Integer year, + @AuthUser(accessType = {AccessType.OPTIONAL}) User user, + @PageableDefault(page = 0, size = 10) Pageable pageable + ) { + Page talks = talkService.getTalks(user, title, year, pageable); + return ResponseEntity.status(HttpStatus.OK).body(talks); + } + + @GetMapping("/{talkId}") + public ResponseEntity getTalk( + @PathVariable("talkId") Long talkId, + @AuthUser(accessType = {AccessType.OPTIONAL}) User user + ) { + TalkUserResponse response = talkService.getTalkById(talkId, user); + return ResponseEntity.status(HttpStatus.OK).body(response); + } + + @PutMapping("/{talkId}") + public ResponseEntity updateTalk( + @PathVariable("talkId") Long talkId, + @RequestBody @Valid TalkRequest talkRequest, + @AuthUser(accessType = {AccessType.ADMIN}) User user + ) { + TalkResponse response = talkService.updateTalk(talkId, talkRequest); + return ResponseEntity.status(HttpStatus.OK).body(response); + } + + @DeleteMapping("/{talkId}") + public ResponseEntity deleteTalk( + @PathVariable("talkId") Long talkId, + @AuthUser(accessType = {AccessType.ADMIN}) User user + ) { + talkService.deleteTalk(talkId); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + @GetMapping("/{talkId}/quiz") + public ResponseEntity getQuiz(@PathVariable("talkId") Long talkId) { + QuizResponse quizResponse = quizService.getQuiz(talkId); + return ResponseEntity.status(HttpStatus.OK).body(quizResponse); + } + + @PostMapping("/{talkId}/quiz") + public ResponseEntity submitQuiz( + @PathVariable("talkId") Long talkId, + @AuthUser(accessType = {AccessType.ALL}) User user, + @RequestBody @Valid QuizSubmitRequest request + ) { + QuizSubmitResponse response = quizService.submitQuiz(talkId, request, user); + return ResponseEntity.status(HttpStatus.OK).body(response); + } + + @GetMapping("/{talkId}/quiz/submit") + public ResponseEntity getUserQuiz( + @PathVariable("talkId") Long talkId, + @AuthUser(accessType = {AccessType.ALL}) User user + ) { + QuizSubmitResponse response = quizService.getUserQuiz(talkId, user); + return ResponseEntity.status(HttpStatus.OK).body(response); + } + + @PostMapping("/{talkId}/favorite") + public ResponseEntity createTalkFavorite( + @PathVariable("talkId") Long talkId, + @AuthUser(accessType = {AccessType.ALL}) User user + ) { + favoriteVideoService.createTalkFavorite(talkId, user); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @DeleteMapping("/{talkId}/favorite") + public ResponseEntity deleteTalkFavorite( + @PathVariable("talkId") Long talkId, + @AuthUser(accessType = {AccessType.ALL}) User user + ) { + favoriteVideoService.deleteTalkFavorite(talkId, user); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } +} diff --git a/src/main/java/com/scg/stop/video/domain/FavoriteVideo.java b/src/main/java/com/scg/stop/video/domain/FavoriteVideo.java new file mode 100644 index 00000000..15b6a443 --- /dev/null +++ b/src/main/java/com/scg/stop/video/domain/FavoriteVideo.java @@ -0,0 +1,57 @@ +package com.scg.stop.video.domain; + +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import com.scg.stop.user.domain.User; +import com.scg.stop.global.domain.BaseTimeEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = PROTECTED) +public class FavoriteVideo extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "talk_id") + private Talk talk; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "job_interview_id") + private JobInterview jobInterview; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "user_id") + private User user; + + private FavoriteVideo(Talk talk, User user) { + this.talk = talk; + this.user = user; + } + + private FavoriteVideo(JobInterview jobInterview, User user) { + this.jobInterview = jobInterview; + this.user = user; + } + + public static FavoriteVideo of(Talk talk, User user) { + return new FavoriteVideo(talk, user); + } + + public static FavoriteVideo of(JobInterview jobInterview, User user) { + return new FavoriteVideo(jobInterview, user); + } + + +} diff --git a/src/main/java/com/scg/stop/video/domain/JobInterview.java b/src/main/java/com/scg/stop/video/domain/JobInterview.java new file mode 100644 index 00000000..61060662 --- /dev/null +++ b/src/main/java/com/scg/stop/video/domain/JobInterview.java @@ -0,0 +1,102 @@ +package com.scg.stop.video.domain; + +import static jakarta.persistence.EnumType.STRING; +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PRIVATE; +import static lombok.AccessLevel.PROTECTED; + +import com.scg.stop.global.domain.BaseTimeEntity; +import jakarta.persistence.*; + +import java.util.ArrayList; +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@AllArgsConstructor(access = PRIVATE) +@NoArgsConstructor(access = PROTECTED) +public class JobInterview extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @Column(nullable = false) + private String title; + + @Column(nullable = false) + private String youtubeId; + + @Column(nullable = false) + private Integer year; + + @Column(nullable = false) + private String talkerBelonging; + + @Column(nullable = false) + private String talkerName; + + @Column(nullable = false) + @Enumerated(value = STRING) + private JobInterviewCategory category; + + @OneToMany(fetch = LAZY, mappedBy = "jobInterview", cascade = CascadeType.REMOVE, orphanRemoval = true) + private List favoriteVideos; + + private JobInterview(String title, String youtubeId, Integer year, String talkerBelonging, String talkerName, JobInterviewCategory category) { + this.title = title; + this.youtubeId = youtubeId; + this.year = year; + this.talkerBelonging = talkerBelonging; + this.talkerName = talkerName; + this.category = category; + this.favoriteVideos = new ArrayList<>(); + } + + public static JobInterview from( + String title, + String youtubeId, + Integer year, + String talkerBelonging, + String talkerName, + JobInterviewCategory category + ) { + return new JobInterview( + title, + youtubeId, + year, + talkerBelonging, + talkerName, + category + ); + } + + public void updateJobInterview( + String title, + String youtubeId, + Integer year, + String talkerBelonging, + String talkerName, + JobInterviewCategory category + ) { + this.title = title; + this.youtubeId = youtubeId; + this.year = year; + this.talkerBelonging = talkerBelonging; + this.talkerName = talkerName; + this.category = category; + } + + public void addFavoriteVideo(FavoriteVideo favoriteVideo) { + this.favoriteVideos.add(favoriteVideo); + } + + public void removeFavoriteVideo(FavoriteVideo favoriteVideo) { + this.favoriteVideos.remove(favoriteVideo); + } +} diff --git a/src/main/java/com/scg/stop/video/domain/JobInterviewCategory.java b/src/main/java/com/scg/stop/video/domain/JobInterviewCategory.java new file mode 100644 index 00000000..2c3baac6 --- /dev/null +++ b/src/main/java/com/scg/stop/video/domain/JobInterviewCategory.java @@ -0,0 +1,7 @@ +package com.scg.stop.video.domain; + +public enum JobInterviewCategory { + + SENIOR, + INTERN +} diff --git a/src/main/java/com/scg/stop/video/domain/Quiz.java b/src/main/java/com/scg/stop/video/domain/Quiz.java new file mode 100644 index 00000000..2fdc42eb --- /dev/null +++ b/src/main/java/com/scg/stop/video/domain/Quiz.java @@ -0,0 +1,60 @@ +package com.scg.stop.video.domain; + +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import com.scg.stop.global.domain.BaseTimeEntity; +import io.hypersistence.utils.hibernate.type.json.JsonType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.Type; + +@Entity +@Getter +@NoArgsConstructor(access = PROTECTED) +public class Quiz extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @Type(JsonType.class) + @Column(nullable = false, columnDefinition = "TEXT") + private Map quiz; + + @OneToOne(fetch = LAZY) + @JoinColumn(name = "talk_id") + @Setter + private Talk talk; + + @OneToMany(fetch = LAZY, mappedBy = "quiz") + private List userQuizzes = new ArrayList<>(); + + public Quiz( + Map quiz + ) { + this.quiz = quiz; + } + + public static Quiz from(Map quiz) { + return new Quiz( + quiz + ); + } + + public void updateQuiz(Map quiz) { + this.quiz = quiz; + } +} \ No newline at end of file diff --git a/src/main/java/com/scg/stop/video/domain/QuizInfo.java b/src/main/java/com/scg/stop/video/domain/QuizInfo.java new file mode 100644 index 00000000..46adb7cf --- /dev/null +++ b/src/main/java/com/scg/stop/video/domain/QuizInfo.java @@ -0,0 +1,18 @@ +package com.scg.stop.video.domain; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class QuizInfo { + + private String question; + private Integer answer; + private List options; +} diff --git a/src/main/java/com/scg/stop/video/domain/Talk.java b/src/main/java/com/scg/stop/video/domain/Talk.java new file mode 100644 index 00000000..d899ffc4 --- /dev/null +++ b/src/main/java/com/scg/stop/video/domain/Talk.java @@ -0,0 +1,101 @@ +package com.scg.stop.video.domain; + +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import com.scg.stop.global.domain.BaseTimeEntity; +import jakarta.persistence.*; + +import java.util.ArrayList; +import java.util.List; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = PROTECTED) +public class Talk extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @Column(nullable = false) + private String title; + + @Column(nullable = false) + private String youtubeId; + + @Column(nullable = false) + private Integer year; + + @Column(nullable = false) + private String talkerBelonging; + + @Column(nullable = false) + private String talkerName; + + @OneToOne(fetch = LAZY, mappedBy = "talk", cascade = CascadeType.REMOVE, orphanRemoval = true) + private Quiz quiz; + + @OneToMany(fetch = LAZY, mappedBy = "talk", cascade = CascadeType.REMOVE, orphanRemoval = true) + private List favoriteVideos; + + public Talk(String title, String youtubeId, Integer year, String talkerBelonging, String talkerName) { + this.title = title; + this.youtubeId = youtubeId; + this.year = year; + this.talkerBelonging = talkerBelonging; + this.talkerName = talkerName; + this.favoriteVideos = new ArrayList<>(); + } + public static Talk from( + String title, + String youtubeId, + Integer year, + String talkerBelonging, + String talkerName + ) { + return new Talk( + title, + youtubeId, + year, + talkerBelonging, + talkerName + ); + } + + public void updateTalk( + String title, + String youtubeId, + Integer year, + String talkerBelonging, + String talkerName + ) { + this.title= title; + this.youtubeId = youtubeId; + this.year = year; + this.talkerBelonging = talkerBelonging; + this.talkerName = talkerName; + } + + public void setQuiz(Quiz quiz) { + if(this.quiz != null) { + this.quiz.setTalk(null); + } + this.quiz = quiz; + if(quiz != null) { + quiz.setTalk(this); + } + } + + public void addFavoriteVideo(FavoriteVideo favoriteVideo) { + this.favoriteVideos.add(favoriteVideo); + } + + public void removeFavoriteVideo(FavoriteVideo favoriteVideo) { + this.favoriteVideos.remove(favoriteVideo); + } + +} diff --git a/src/main/java/com/scg/stop/video/domain/UserQuiz.java b/src/main/java/com/scg/stop/video/domain/UserQuiz.java new file mode 100644 index 00000000..5f3cbf43 --- /dev/null +++ b/src/main/java/com/scg/stop/video/domain/UserQuiz.java @@ -0,0 +1,55 @@ +package com.scg.stop.video.domain; + +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import com.scg.stop.user.domain.User; +import com.scg.stop.global.domain.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = PROTECTED) +public class UserQuiz extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @Column(nullable = false) + private Integer tryCount = 1; + + @Column(nullable = false, columnDefinition = "TINYINT(1)") + private boolean isSuccess = false; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "quiz_id") + private Quiz quiz; + + private UserQuiz(User user, Quiz quiz, boolean isSuccess) { + this.user = user; + this.quiz = quiz; + this.isSuccess = isSuccess; + } + + public static UserQuiz from(User user, Quiz quiz, boolean isSuccess) { + return new UserQuiz(user, quiz, isSuccess); + } + + public void updateSuccess(boolean success) { + isSuccess = success; + tryCount = tryCount + 1; + } +} diff --git a/src/main/java/com/scg/stop/video/dto/request/JobInterviewRequest.java b/src/main/java/com/scg/stop/video/dto/request/JobInterviewRequest.java new file mode 100644 index 00000000..572afb1f --- /dev/null +++ b/src/main/java/com/scg/stop/video/dto/request/JobInterviewRequest.java @@ -0,0 +1,40 @@ +package com.scg.stop.video.dto.request; + +import com.scg.stop.video.domain.JobInterview; +import com.scg.stop.video.domain.JobInterviewCategory; +import com.scg.stop.global.validation.ValidEnum; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@AllArgsConstructor +@NoArgsConstructor +@Getter +public class JobInterviewRequest { + @NotBlank(message = "제목을 입력해주세요.") + private String title; + + @NotBlank(message = "유튜브 URL의 ID를 입력해주세요.") + private String youtubeId; + + @NotNull(message = "연도를 입력해주세요.") + private Integer year; + + @NotBlank(message = "대담자의 소속을 입력해주세요.") + public String talkerBelonging; + + @NotBlank(message = "대담자의 성명을 입력해주세요.") + public String talkerName; + + @ValidEnum(enumClass = JobInterviewCategory.class) + @NotNull(message = "카테고리를 입력해주세요.") + private JobInterviewCategory category; + + public JobInterview toEntity() { + return JobInterview.from( + title, youtubeId, year, talkerBelonging, talkerName, category + ); + } +} diff --git a/src/main/java/com/scg/stop/video/dto/request/QuizInfoRequest.java b/src/main/java/com/scg/stop/video/dto/request/QuizInfoRequest.java new file mode 100644 index 00000000..ed5600f9 --- /dev/null +++ b/src/main/java/com/scg/stop/video/dto/request/QuizInfoRequest.java @@ -0,0 +1,34 @@ +package com.scg.stop.video.dto.request; + +import com.scg.stop.video.domain.QuizInfo; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@NoArgsConstructor +@AllArgsConstructor +@Getter +public class QuizInfoRequest { + @NotBlank(message = "퀴즈의 질문을 입력해주세요.") + private String question; + + @NotNull(message = "올바른 퀴즈의 정답번호를 입력해주세요.") + private Integer answer; + + @NotEmpty(message = "1개 이상의 퀴즈 선지를 입력해주세요.") + private List options; + + public QuizInfo toQuizInfo() { + QuizInfo quizInfo = new QuizInfo(); + quizInfo.setQuestion(question); + quizInfo.setAnswer(answer); + quizInfo.setOptions(options); + return quizInfo; + } + +} diff --git a/src/main/java/com/scg/stop/video/dto/request/QuizRequest.java b/src/main/java/com/scg/stop/video/dto/request/QuizRequest.java new file mode 100644 index 00000000..8cf8a984 --- /dev/null +++ b/src/main/java/com/scg/stop/video/dto/request/QuizRequest.java @@ -0,0 +1,38 @@ +package com.scg.stop.video.dto.request; + +import com.scg.stop.video.domain.Quiz; +import com.scg.stop.video.domain.QuizInfo; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@NoArgsConstructor +@AllArgsConstructor +@Getter +public class QuizRequest { + + //@NotNull(message = "퀴즈를 입력해주세요.") + @Size(min=1, message = "퀴즈는 1개 이상이어야 합니다.") + @Valid + public List<@Valid QuizInfoRequest> quiz; + + public Quiz toEntity() { return Quiz.from( + this.toQuizInfoMap() + ); } + + public Map toQuizInfoMap() { + Map quizInfoMap = new HashMap<>(); + for(int i = 0; i < quiz.size(); i++) { + quizInfoMap.put(Integer.toString(i), quiz.get(i).toQuizInfo()); + } + return quizInfoMap; + } + +} diff --git a/src/main/java/com/scg/stop/video/dto/request/QuizSubmitRequest.java b/src/main/java/com/scg/stop/video/dto/request/QuizSubmitRequest.java new file mode 100644 index 00000000..201d0535 --- /dev/null +++ b/src/main/java/com/scg/stop/video/dto/request/QuizSubmitRequest.java @@ -0,0 +1,19 @@ +package com.scg.stop.video.dto.request; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Map; + +@NoArgsConstructor +@AllArgsConstructor +@Getter +public class QuizSubmitRequest { + + @NotNull(message = "퀴즈 제출 결과를 입력해주세요.") + @Size(min = 1, message = "퀴즈 제출 결과를 1개 이상 입력해주세요.") + Map result; +} diff --git a/src/main/java/com/scg/stop/video/dto/request/TalkRequest.java b/src/main/java/com/scg/stop/video/dto/request/TalkRequest.java new file mode 100644 index 00000000..7741213f --- /dev/null +++ b/src/main/java/com/scg/stop/video/dto/request/TalkRequest.java @@ -0,0 +1,39 @@ +package com.scg.stop.video.dto.request; + +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import com.scg.stop.video.domain.Talk; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + + +@NoArgsConstructor +@AllArgsConstructor +@Getter +public class TalkRequest { + + @NotBlank(message="제목을 입력해주세요.") + public String title; + + @NotBlank(message="유튜브 URL의 ID를 입력해주세요.") + public String youtubeId; + + @NotNull(message = "연도를 입력해주세요.") + public Integer year; + + @NotBlank(message = "대담자의 소속을 입력해주세요.") + public String talkerBelonging; + + @NotBlank(message = "대담자의 성명을 입력해주세요.") + public String talkerName; + + @JsonUnwrapped + @Valid + public QuizRequest quiz; + + public Talk toEntity() {return Talk.from( + title, youtubeId, year, talkerBelonging, talkerName + ); } +} diff --git a/src/main/java/com/scg/stop/video/dto/response/JobInterviewResponse.java b/src/main/java/com/scg/stop/video/dto/response/JobInterviewResponse.java new file mode 100644 index 00000000..0b7aac5e --- /dev/null +++ b/src/main/java/com/scg/stop/video/dto/response/JobInterviewResponse.java @@ -0,0 +1,37 @@ +package com.scg.stop.video.dto.response; + +import com.scg.stop.video.domain.JobInterview; +import com.scg.stop.video.domain.JobInterviewCategory; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@AllArgsConstructor +public class JobInterviewResponse { + private Long id; + private String title; + private String youtubeId; + private Integer year; + private String talkerBelonging; + private String talkerName; + private JobInterviewCategory category; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + // Entity to DTO + public static JobInterviewResponse from(JobInterview jobInterview) { + return new JobInterviewResponse( + jobInterview.getId(), + jobInterview.getTitle(), + jobInterview.getYoutubeId(), + jobInterview.getYear(), + jobInterview.getTalkerBelonging(), + jobInterview.getTalkerName(), + jobInterview.getCategory(), + jobInterview.getCreatedAt(), + jobInterview.getUpdatedAt() + ); + } +} diff --git a/src/main/java/com/scg/stop/video/dto/response/JobInterviewUserResponse.java b/src/main/java/com/scg/stop/video/dto/response/JobInterviewUserResponse.java new file mode 100644 index 00000000..d61b5816 --- /dev/null +++ b/src/main/java/com/scg/stop/video/dto/response/JobInterviewUserResponse.java @@ -0,0 +1,55 @@ +package com.scg.stop.video.dto.response; + +import com.scg.stop.video.domain.JobInterview; +import com.scg.stop.video.domain.JobInterviewCategory; +import lombok.AllArgsConstructor; +import lombok.Getter; +import java.time.LocalDateTime; + +@Getter +@AllArgsConstructor +public class JobInterviewUserResponse { + + private Long id; + private String title; + private String youtubeId; + private Integer year; + private String talkerBelonging; + private String talkerName; + private boolean favorite; + private JobInterviewCategory category; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + // Entity to DTO + public static JobInterviewUserResponse from(JobInterview jobInterview) { + return new JobInterviewUserResponse( + jobInterview.getId(), + jobInterview.getTitle(), + jobInterview.getYoutubeId(), + jobInterview.getYear(), + jobInterview.getTalkerBelonging(), + jobInterview.getTalkerName(), + false, + jobInterview.getCategory(), + jobInterview.getCreatedAt(), + jobInterview.getUpdatedAt() + ); + } + + public static JobInterviewUserResponse from(JobInterview jobInterview, boolean isFavorite) { + return new JobInterviewUserResponse( + jobInterview.getId(), + jobInterview.getTitle(), + jobInterview.getYoutubeId(), + jobInterview.getYear(), + jobInterview.getTalkerBelonging(), + jobInterview.getTalkerName(), + isFavorite, + jobInterview.getCategory(), + jobInterview.getCreatedAt(), + jobInterview.getUpdatedAt() + ); + } + +} diff --git a/src/main/java/com/scg/stop/video/dto/response/QuizResponse.java b/src/main/java/com/scg/stop/video/dto/response/QuizResponse.java new file mode 100644 index 00000000..01196925 --- /dev/null +++ b/src/main/java/com/scg/stop/video/dto/response/QuizResponse.java @@ -0,0 +1,28 @@ +package com.scg.stop.video.dto.response; + +import com.scg.stop.video.domain.Quiz; +import com.scg.stop.video.domain.QuizInfo; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class QuizResponse { + public List quiz; + public static QuizResponse from(Quiz quiz) { + List keys = new ArrayList<>(quiz.getQuiz().keySet()); + Collections.sort(keys); + List tmp = new ArrayList<>(); + for(String key : keys) { + tmp.add(quiz.getQuiz().get(key)); + } + return new QuizResponse(tmp); + } +} diff --git a/src/main/java/com/scg/stop/video/dto/response/QuizSubmitResponse.java b/src/main/java/com/scg/stop/video/dto/response/QuizSubmitResponse.java new file mode 100644 index 00000000..39d2b2cc --- /dev/null +++ b/src/main/java/com/scg/stop/video/dto/response/QuizSubmitResponse.java @@ -0,0 +1,11 @@ +package com.scg.stop.video.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class QuizSubmitResponse { + public boolean success; + public Integer tryCount; +} diff --git a/src/main/java/com/scg/stop/video/dto/response/TalkResponse.java b/src/main/java/com/scg/stop/video/dto/response/TalkResponse.java new file mode 100644 index 00000000..6ad239a9 --- /dev/null +++ b/src/main/java/com/scg/stop/video/dto/response/TalkResponse.java @@ -0,0 +1,41 @@ +package com.scg.stop.video.dto.response; + +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import com.scg.stop.video.domain.Talk; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@AllArgsConstructor +public class TalkResponse { + public Long id; + public String title; + public String youtubeId; + public Integer year; + public String talkerBelonging; + public String talkerName; + + @JsonUnwrapped + public QuizResponse quiz; + + public LocalDateTime createdAt; + public LocalDateTime updatedAt; + + + public static TalkResponse from(Talk talk) { + return new TalkResponse( + talk.getId(), + talk.getTitle(), + talk.getYoutubeId(), + talk.getYear(), + talk.getTalkerBelonging(), + talk.getTalkerName(), + (talk.getQuiz() != null)? QuizResponse.from(talk.getQuiz()) : new QuizResponse(), + talk.getCreatedAt(), + talk.getUpdatedAt() + ); + } + +} diff --git a/src/main/java/com/scg/stop/video/dto/response/TalkUserResponse.java b/src/main/java/com/scg/stop/video/dto/response/TalkUserResponse.java new file mode 100644 index 00000000..01b03e56 --- /dev/null +++ b/src/main/java/com/scg/stop/video/dto/response/TalkUserResponse.java @@ -0,0 +1,56 @@ +package com.scg.stop.video.dto.response; + +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import com.scg.stop.video.domain.Talk; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@AllArgsConstructor +public class TalkUserResponse { + public Long id; + public String title; + public String youtubeId; + public Integer year; + public String talkerBelonging; + public String talkerName; + public boolean favorite; + + @JsonUnwrapped + public QuizResponse quiz; + + public LocalDateTime createdAt; + public LocalDateTime updatedAt; + + public static TalkUserResponse from(Talk talk) { + return new TalkUserResponse( + talk.getId(), + talk.getTitle(), + talk.getYoutubeId(), + talk.getYear(), + talk.getTalkerBelonging(), + talk.getTalkerName(), + false, + (talk.getQuiz() != null)? QuizResponse.from(talk.getQuiz()) : new QuizResponse(), + talk.getCreatedAt(), + talk.getUpdatedAt() + ); + } + + public static TalkUserResponse from(Talk talk, boolean favorite) { + return new TalkUserResponse( + talk.getId(), + talk.getTitle(), + talk.getYoutubeId(), + talk.getYear(), + talk.getTalkerBelonging(), + talk.getTalkerName(), + favorite, + (talk.getQuiz() != null)? QuizResponse.from(talk.getQuiz()) : new QuizResponse(), + talk.getCreatedAt(), + talk.getUpdatedAt() + ); + } +} diff --git a/src/main/java/com/scg/stop/video/dto/response/UserQuizResultExcelResponse.java b/src/main/java/com/scg/stop/video/dto/response/UserQuizResultExcelResponse.java new file mode 100644 index 00000000..d38f76cd --- /dev/null +++ b/src/main/java/com/scg/stop/video/dto/response/UserQuizResultExcelResponse.java @@ -0,0 +1,22 @@ +package com.scg.stop.video.dto.response; + +import com.scg.stop.global.excel.annotation.ExcelColumn; +import com.scg.stop.global.excel.annotation.ExcelDownload; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@ExcelDownload( + fileName = "UserQuiz" +) +public class UserQuizResultExcelResponse { + @ExcelColumn(headerName = "이름") + private String name; + @ExcelColumn(headerName = "휴대전화") + private String phone; + @ExcelColumn(headerName = "이메일") + private String email; + @ExcelColumn(headerName = "성공횟수") + private Long successCount; +} diff --git a/src/main/java/com/scg/stop/video/dto/response/UserQuizResultResponse.java b/src/main/java/com/scg/stop/video/dto/response/UserQuizResultResponse.java new file mode 100644 index 00000000..874a252e --- /dev/null +++ b/src/main/java/com/scg/stop/video/dto/response/UserQuizResultResponse.java @@ -0,0 +1,15 @@ +package com.scg.stop.video.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class UserQuizResultResponse { + + private Long userId; + private String name; + private String phone; + private String email; + private Long successCount; +} diff --git a/src/main/java/com/scg/stop/video/repository/FavoriteVideoRepository.java b/src/main/java/com/scg/stop/video/repository/FavoriteVideoRepository.java new file mode 100644 index 00000000..b3268c35 --- /dev/null +++ b/src/main/java/com/scg/stop/video/repository/FavoriteVideoRepository.java @@ -0,0 +1,25 @@ +package com.scg.stop.video.repository; + +import com.scg.stop.user.domain.User; +import com.scg.stop.video.domain.FavoriteVideo; +import com.scg.stop.video.domain.JobInterview; +import com.scg.stop.video.domain.Talk; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface FavoriteVideoRepository extends JpaRepository { + FavoriteVideo findByTalkAndUser(Talk talk, User user); + FavoriteVideo findByJobInterviewAndUser(JobInterview jobInterview, User user); + + boolean existsByTalkAndUser(Talk talk, User user); + boolean existsByJobInterviewAndUser(JobInterview jobInterview, User user); + + @Query("SELECT f.talk FROM FavoriteVideo f WHERE f.user = :user AND f.talk IS NOT NULL") + List findTalksByUser(@Param("user") User user); + + @Query("SELECT f.jobInterview FROM FavoriteVideo f WHERE f.user = :user AND f.jobInterview IS NOT NULL") + List findJobInterviewsByUser(@Param("user") User user); +} diff --git a/src/main/java/com/scg/stop/video/repository/JobInterviewRepository.java b/src/main/java/com/scg/stop/video/repository/JobInterviewRepository.java new file mode 100644 index 00000000..38cc9320 --- /dev/null +++ b/src/main/java/com/scg/stop/video/repository/JobInterviewRepository.java @@ -0,0 +1,23 @@ +package com.scg.stop.video.repository; + +import com.scg.stop.video.domain.JobInterviewCategory; +import com.scg.stop.video.domain.JobInterview; +import com.scg.stop.video.dto.response.JobInterviewResponse; +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; + +public interface JobInterviewRepository extends JpaRepository { + + @Query("SELECT j FROM JobInterview j " + + "WHERE (:year IS NULL OR j.year = :year) " + + "AND (:category IS NULL OR j.category = :category) " + + "AND (:title IS NULL OR j.title LIKE %:title%)" + + "ORDER BY j.year DESC") + Page findJobInterviews(@Param("year") Integer year, + @Param("category") JobInterviewCategory category, + @Param("title") String title, + Pageable pageable); +} \ No newline at end of file diff --git a/src/main/java/com/scg/stop/video/repository/QuizRepository.java b/src/main/java/com/scg/stop/video/repository/QuizRepository.java new file mode 100644 index 00000000..222b7f3f --- /dev/null +++ b/src/main/java/com/scg/stop/video/repository/QuizRepository.java @@ -0,0 +1,10 @@ +package com.scg.stop.video.repository; + +import com.scg.stop.video.domain.Quiz; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface QuizRepository extends JpaRepository { + Optional findByTalkId(Long talkId); +} diff --git a/src/main/java/com/scg/stop/video/repository/TalkRepository.java b/src/main/java/com/scg/stop/video/repository/TalkRepository.java new file mode 100644 index 00000000..724e69c6 --- /dev/null +++ b/src/main/java/com/scg/stop/video/repository/TalkRepository.java @@ -0,0 +1,22 @@ +package com.scg.stop.video.repository; + +import com.scg.stop.video.domain.Talk; +import com.scg.stop.video.dto.response.TalkResponse; +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; + +public interface TalkRepository extends JpaRepository { + + @Query("SELECT t from Talk t "+ + "WHERE (:year IS NULL OR t.year = :year) " + + "AND (:title IS NULL OR t.title LIKE %:title%)" + + "ORDER BY t.year DESC") + Page findPages( + @Param("title") String title, + @Param("year") Integer year, + Pageable pageable + ); +} diff --git a/src/main/java/com/scg/stop/video/repository/UserQuizRepository.java b/src/main/java/com/scg/stop/video/repository/UserQuizRepository.java new file mode 100644 index 00000000..0fdb3e1f --- /dev/null +++ b/src/main/java/com/scg/stop/video/repository/UserQuizRepository.java @@ -0,0 +1,36 @@ +package com.scg.stop.video.repository; + +import com.scg.stop.user.domain.User; +import com.scg.stop.video.domain.Quiz; +import com.scg.stop.video.domain.UserQuiz; +import com.scg.stop.video.dto.response.UserQuizResultExcelResponse; +import com.scg.stop.video.dto.response.UserQuizResultResponse; +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.util.List; + +public interface UserQuizRepository extends JpaRepository { + UserQuiz findByUserAndQuiz(User user, Quiz quiz); + + + @Query("SELECT new com.scg.stop.video.dto.response.UserQuizResultResponse(u.id, u.name, u.phone, u.email, SUM(CASE WHEN uq.isSuccess = true THEN 1 ELSE 0 END)) " + + "FROM User u " + + "LEFT JOIN UserQuiz uq ON u.id = uq.user.id " + + "WHERE (:year IS NULL OR YEAR(uq.createdAt) = :year) " + + "GROUP BY u.id, u.name, u.phone, u.email " + + "ORDER BY SUM(CASE WHEN uq.isSuccess = true THEN 1 ELSE 0 END) DESC") + Page findUserQuizResults(@Param("year") Integer year, Pageable pageable); + + @Query("SELECT new com.scg.stop.video.dto.response.UserQuizResultExcelResponse(u.name, u.phone, u.email, SUM(CASE WHEN uq.isSuccess = true THEN 1 ELSE 0 END)) " + + "FROM User u " + + "LEFT JOIN UserQuiz uq ON u.id = uq.user.id " + + "WHERE (:year IS NULL OR YEAR(uq.createdAt) = :year) " + + "GROUP BY u.id, u.name, u.phone, u.email " + + "ORDER BY SUM(CASE WHEN uq.isSuccess = true THEN 1 ELSE 0 END) DESC") + List findAllByYear(@Param("year") Integer year); + +} diff --git a/src/main/java/com/scg/stop/video/service/FavoriteVideoService.java b/src/main/java/com/scg/stop/video/service/FavoriteVideoService.java new file mode 100644 index 00000000..4e0bada8 --- /dev/null +++ b/src/main/java/com/scg/stop/video/service/FavoriteVideoService.java @@ -0,0 +1,78 @@ +package com.scg.stop.video.service; + +import com.scg.stop.global.exception.BadRequestException; +import com.scg.stop.global.exception.ExceptionCode; +import com.scg.stop.user.domain.User; +import com.scg.stop.user.repository.UserRepository; +import com.scg.stop.video.domain.FavoriteVideo; +import com.scg.stop.video.domain.JobInterview; +import com.scg.stop.video.domain.Talk; +import com.scg.stop.video.repository.FavoriteVideoRepository; +import com.scg.stop.video.repository.JobInterviewRepository; +import com.scg.stop.video.repository.TalkRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class FavoriteVideoService { + private final FavoriteVideoRepository favoriteVideoRepository; + private final JobInterviewRepository jobInterviewRepository; + private final TalkRepository talkRepository; + private final UserRepository userRepository; + + public void createJobInterviewFavorite(Long id, User user) { + JobInterview jobInterview = jobInterviewRepository.findById(id).orElseThrow(() -> + new BadRequestException(ExceptionCode.ID_NOT_FOUND)); + FavoriteVideo favoriteVideo = favoriteVideoRepository.findByJobInterviewAndUser(jobInterview, user); + if (favoriteVideo != null) { + throw new BadRequestException(ExceptionCode.ALREADY_FAVORITE); + } + FavoriteVideo newFavoriteVideo = favoriteVideoRepository.save(FavoriteVideo.of(jobInterview, user)); + jobInterview.addFavoriteVideo(newFavoriteVideo); + userRepository.save(user); + + + } + + public void deleteJobInterviewFavorite(Long id, User user) { + JobInterview jobInterview = jobInterviewRepository.findById(id).orElseThrow(() -> + new BadRequestException(ExceptionCode.ID_NOT_FOUND)); + FavoriteVideo favoriteVideo = favoriteVideoRepository.findByJobInterviewAndUser(jobInterview, user); + if(favoriteVideo == null) { + throw new BadRequestException(ExceptionCode.NOT_FAVORITE); + } + jobInterview.removeFavoriteVideo(favoriteVideo); + favoriteVideoRepository.delete(favoriteVideo); + userRepository.save(user); + } + + + + public void createTalkFavorite(Long id, User user) { + Talk talk = talkRepository.findById(id).orElseThrow(() -> + new BadRequestException(ExceptionCode.TALK_ID_NOT_FOUND)); + FavoriteVideo favoriteVideo = favoriteVideoRepository.findByTalkAndUser(talk, user); + if(favoriteVideo != null) { + throw new BadRequestException(ExceptionCode.ALREADY_FAVORITE); + } + FavoriteVideo newFavoriteVideo = favoriteVideoRepository.save(FavoriteVideo.of(talk, user)); + talk.addFavoriteVideo(newFavoriteVideo); + userRepository.save(user); + + } + + public void deleteTalkFavorite(Long id, User user) { + Talk talk = talkRepository.findById(id).orElseThrow(() -> + new BadRequestException(ExceptionCode.TALK_ID_NOT_FOUND)); + FavoriteVideo favoriteVideo = favoriteVideoRepository.findByTalkAndUser(talk, user); + if(favoriteVideo == null) { + throw new BadRequestException(ExceptionCode.NOT_FAVORITE); + } + talk.removeFavoriteVideo(favoriteVideo); + favoriteVideoRepository.delete(favoriteVideo); + userRepository.save(user); + } +} diff --git a/src/main/java/com/scg/stop/video/service/JobInterviewService.java b/src/main/java/com/scg/stop/video/service/JobInterviewService.java new file mode 100644 index 00000000..f9504969 --- /dev/null +++ b/src/main/java/com/scg/stop/video/service/JobInterviewService.java @@ -0,0 +1,85 @@ +package com.scg.stop.video.service; + +import com.scg.stop.user.domain.User; +import com.scg.stop.video.domain.FavoriteVideo; +import com.scg.stop.video.domain.JobInterviewCategory; +import com.scg.stop.video.domain.JobInterview; +import com.scg.stop.video.dto.request.JobInterviewRequest; +import com.scg.stop.video.dto.response.JobInterviewResponse; +import com.scg.stop.video.dto.response.JobInterviewUserResponse; +import com.scg.stop.video.repository.FavoriteVideoRepository; +import com.scg.stop.video.repository.JobInterviewRepository; +import com.scg.stop.global.exception.BadRequestException; +import com.scg.stop.global.exception.ExceptionCode; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class JobInterviewService { + private final JobInterviewRepository jobInterviewRepository; + private final FavoriteVideoRepository favoriteVideoRepository; + + @Transactional(readOnly = true) + public JobInterviewUserResponse getJobInterview(Long id, User user) { + JobInterview jobInterview = jobInterviewRepository.findById(id).orElseThrow(() -> + new BadRequestException(ExceptionCode.ID_NOT_FOUND)); + + if(user == null) return JobInterviewUserResponse.from(jobInterview); + return JobInterviewUserResponse.from(jobInterview, favoriteVideoRepository.existsByJobInterviewAndUser(jobInterview, user)); + } + + @Transactional(readOnly = true) + public Page getJobInterviews(User user, Integer year, JobInterviewCategory category, String title, Pageable pageable) { + Page jobInterviews = jobInterviewRepository.findJobInterviews(year, category, title, pageable); + if(user == null) return jobInterviews.map(JobInterviewUserResponse::from); + List content = jobInterviews.stream().map(jobInterview -> JobInterviewUserResponse.from( + jobInterview, + favoriteVideoRepository.existsByJobInterviewAndUser(jobInterview, user) + )).toList(); + return new PageImpl<>(content, pageable, jobInterviews.getTotalElements()); + } + + public JobInterviewResponse createJobInterview(JobInterviewRequest req) { + JobInterview newJobInterview = jobInterviewRepository.save( + JobInterview.from( + req.getTitle(), + req.getYoutubeId(), + req.getYear(), + req.getTalkerBelonging(), + req.getTalkerName(), + req.getCategory() + ) + ); + return JobInterviewResponse.from(newJobInterview); + } + + public void deleteJobInterviewById(Long id) { + JobInterview jobInterview = jobInterviewRepository.findById(id).orElseThrow(() -> + new BadRequestException(ExceptionCode.ID_NOT_FOUND)); + jobInterviewRepository.delete(jobInterview); + } + + public JobInterviewResponse updateJobInterview(Long id, JobInterviewRequest req) { + JobInterview jobInterview = jobInterviewRepository.findById(id).orElseThrow(() -> + new BadRequestException(ExceptionCode.ID_NOT_FOUND)); + jobInterview.updateJobInterview( + req.getTitle(), + req.getYoutubeId(), + req.getYear(), + req.getTalkerBelonging(), + req.getTalkerName(), + req.getCategory() + ); + return JobInterviewResponse.from(jobInterview); + } + + +} diff --git a/src/main/java/com/scg/stop/video/service/QuizService.java b/src/main/java/com/scg/stop/video/service/QuizService.java new file mode 100644 index 00000000..5d91ac83 --- /dev/null +++ b/src/main/java/com/scg/stop/video/service/QuizService.java @@ -0,0 +1,129 @@ +package com.scg.stop.video.service; + +import com.scg.stop.event.domain.EventPeriod; +import com.scg.stop.event.repository.EventPeriodRepository; +import com.scg.stop.global.excel.Excel; +import com.scg.stop.global.excel.ExcelUtil; +import com.scg.stop.global.exception.BadRequestException; +import com.scg.stop.global.exception.ExceptionCode; +import com.scg.stop.user.domain.User; +import com.scg.stop.user.repository.UserRepository; +import com.scg.stop.video.domain.Quiz; +import com.scg.stop.video.domain.QuizInfo; +import com.scg.stop.video.domain.Talk; +import com.scg.stop.video.domain.UserQuiz; +import com.scg.stop.video.dto.request.QuizSubmitRequest; +import com.scg.stop.video.dto.response.QuizResponse; +import com.scg.stop.video.dto.response.QuizSubmitResponse; +import com.scg.stop.video.dto.response.UserQuizResultExcelResponse; +import com.scg.stop.video.dto.response.UserQuizResultResponse; +import com.scg.stop.video.repository.QuizRepository; +import com.scg.stop.video.repository.TalkRepository; +import com.scg.stop.video.repository.UserQuizRepository; +import lombok.RequiredArgsConstructor; +import org.apache.poi.xssf.streaming.SXSSFWorkbook; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@Transactional +@RequiredArgsConstructor +public class QuizService { + private final TalkRepository talkRepository; + private final QuizRepository quizRepository; + private final UserQuizRepository userQuizRepository; + private final EventPeriodRepository eventPeriodRepository; + private final UserRepository userRepository; + + private final ExcelUtil excelUtil; + + @Transactional(readOnly = true) + public QuizResponse getQuiz(Long talkId) { + Talk talk = talkRepository.findById(talkId).orElseThrow( + () -> new BadRequestException(ExceptionCode.TALK_ID_NOT_FOUND) + ); + if(talk.getQuiz() == null) throw new BadRequestException(ExceptionCode.NO_QUIZ); + return QuizResponse.from(talk.getQuiz()); + } + + public QuizSubmitResponse submitQuiz(Long talkId, QuizSubmitRequest submitRequest, User user) { + Talk talk = talkRepository.findById(talkId).orElseThrow( + () -> new BadRequestException(ExceptionCode.TALK_ID_NOT_FOUND) + ); + if(talk.getQuiz() == null) throw new BadRequestException(ExceptionCode.NO_QUIZ); + int currentYear = LocalDateTime.now().getYear(); + if(talk.getYear() != currentYear) { + throw new BadRequestException(ExceptionCode.MISMATCH_CURRENT_YEAR); + } + Quiz quiz = talk.getQuiz(); + EventPeriod currentPeriod = eventPeriodRepository.findByYear(currentYear) + .orElseThrow( () -> new BadRequestException(ExceptionCode.NOT_EVENT_PERIOD)); + + LocalDateTime currentTime = LocalDateTime.now(); + if(!(currentTime.isAfter(currentPeriod.getStart()) && currentTime.isBefore(currentPeriod.getEnd()))) { + throw new BadRequestException(ExceptionCode.NOT_EVENT_PERIOD); + } + boolean isSuccess = true; + for(Map.Entry entry : quiz.getQuiz().entrySet()) { + int correctAnswer = entry.getValue().getAnswer(); + int userAnswer = submitRequest.getResult().getOrDefault(entry.getKey(), -1); + if(correctAnswer != userAnswer) { + isSuccess = false; + break; + } + } + UserQuiz userQuiz = userQuizRepository.findByUserAndQuiz(user, quiz); + if(userQuiz == null) { + userQuiz = userQuizRepository.save(UserQuiz.from(user, quiz, isSuccess)); + userRepository.save(user); + } else { + if(userQuiz.getTryCount() >= 3) { + throw new BadRequestException(ExceptionCode.TOO_MANY_TRY_QUIZ); + } + if(!userQuiz.isSuccess()) + userQuiz.updateSuccess(isSuccess); + else throw new BadRequestException(ExceptionCode.ALREADY_QUIZ_SUCCESS); + } + return new QuizSubmitResponse(userQuiz.isSuccess(), userQuiz.getTryCount()); + + } + + @Transactional(readOnly = true) + public Page getQuizResults(Integer year, Pageable pageable) { + if(year == null) { + year = LocalDateTime.now().getYear(); + } + return userQuizRepository.findUserQuizResults(year, pageable); + } + + @Transactional(readOnly = true) + public Excel getQuizResultToExcel(Integer year) { + if(year == null) { + year = LocalDateTime.now().getYear(); + } + List lists = userQuizRepository.findAllByYear(year); + SXSSFWorkbook workbook = excelUtil.createExcel(lists, UserQuizResultExcelResponse.class); + String filename = excelUtil.getFilename(workbook, UserQuizResultExcelResponse.class); + return excelUtil.toExcel(filename, workbook); + } + + @Transactional(readOnly = true) + public QuizSubmitResponse getUserQuiz(Long talkId, User user) { + Talk talk = talkRepository.findById(talkId).orElseThrow( + () -> new BadRequestException(ExceptionCode.TALK_ID_NOT_FOUND) + ); + if(talk.getQuiz() == null) throw new BadRequestException(ExceptionCode.NO_QUIZ); + UserQuiz userQuiz = userQuizRepository.findByUserAndQuiz(user, talk.getQuiz()); + if(userQuiz == null) { + return new QuizSubmitResponse(false, 0); + } + return new QuizSubmitResponse(userQuiz.isSuccess(), userQuiz.getTryCount()); + } + +} diff --git a/src/main/java/com/scg/stop/video/service/TalkService.java b/src/main/java/com/scg/stop/video/service/TalkService.java new file mode 100644 index 00000000..f161854b --- /dev/null +++ b/src/main/java/com/scg/stop/video/service/TalkService.java @@ -0,0 +1,99 @@ +package com.scg.stop.video.service; + +import com.scg.stop.user.domain.User; +import com.scg.stop.video.domain.FavoriteVideo; +import com.scg.stop.video.domain.Quiz; +import com.scg.stop.video.domain.Talk; +import com.scg.stop.video.dto.request.TalkRequest; +import com.scg.stop.video.dto.response.TalkResponse; +import com.scg.stop.video.dto.response.TalkUserResponse; +import com.scg.stop.video.repository.FavoriteVideoRepository; +import com.scg.stop.video.repository.QuizRepository; +import com.scg.stop.video.repository.TalkRepository; +import com.scg.stop.global.exception.BadRequestException; +import com.scg.stop.global.exception.ExceptionCode; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class TalkService { + private final TalkRepository talkRepository; + private final QuizRepository quizRepository; + private final FavoriteVideoRepository favoriteVideoRepository; + + @Transactional(readOnly = true) + public Page getTalks(User user, String title, Integer year, Pageable pageable) { + Page talks = talkRepository.findPages(title, year, pageable); + if(user == null) return talks.map(TalkUserResponse::from); + List content = talks.stream().map(talk -> + TalkUserResponse.from(talk, favoriteVideoRepository.existsByTalkAndUser(talk, user)) + ).toList(); + return new PageImpl<>(content, pageable, talks.getTotalElements()); + } + + @Transactional(readOnly = true) + public TalkUserResponse getTalkById(Long id, User user) { + Talk talk = talkRepository.findById(id).orElseThrow( + () -> new BadRequestException(ExceptionCode.TALK_ID_NOT_FOUND) + ); + if(user == null) return TalkUserResponse.from(talk); + return TalkUserResponse.from(talk, favoriteVideoRepository.existsByTalkAndUser(talk, user)); + } + + public TalkResponse createTalk(TalkRequest talkRequest) { + Talk talk = talkRepository.save(Talk.from( + talkRequest.getTitle(), + talkRequest.getYoutubeId(), + talkRequest.getYear(), + talkRequest.getTalkerBelonging(), + talkRequest.getTalkerName() + )); + if(talkRequest.getQuiz().getQuiz() != null) { + Quiz quiz = quizRepository.save(Quiz.from(talkRequest.getQuiz().toQuizInfoMap())); + talk.setQuiz(quiz); + } + return TalkResponse.from(talk); + } + + public TalkResponse updateTalk(Long id, TalkRequest talkRequest) { + Talk talk = talkRepository.findById(id).orElseThrow( + () -> new BadRequestException(ExceptionCode.TALK_ID_NOT_FOUND) + ); + + if(talk.getQuiz() != null && talkRequest.getQuiz().getQuiz() == null) { // 기존에 퀴즈가 있는데, 퀴즈를 지울때 + quizRepository.delete(talk.getQuiz()); + talk.setQuiz(null); + } + else if(talk.getQuiz() != null) { // 기존에 퀴즈가 있어, 퀴즈를 업데이트 할 때 + talk.getQuiz().updateQuiz(talkRequest.quiz.toQuizInfoMap()); + } + else if(talkRequest.getQuiz().getQuiz() != null) { // 기존에 없고, 새로 퀴즈가 생길때 + Quiz quiz = quizRepository.save(Quiz.from(talkRequest.getQuiz().toQuizInfoMap())); + talk.setQuiz(quiz); + } + talk.updateTalk( + talkRequest.getTitle(), + talkRequest.getYoutubeId(), + talkRequest.getYear(), + talkRequest.getTalkerBelonging(), + talkRequest.getTalkerName() + ); + return TalkResponse.from(talk); + } + + public void deleteTalk(Long id) { + Talk talk = talkRepository.findById(id).orElseThrow( + () -> new BadRequestException(ExceptionCode.TALK_ID_NOT_FOUND) + ); + talkRepository.delete(talk); + } + +} diff --git a/src/main/resources/application-common.yml b/src/main/resources/application-common.yml index e0488385..9b7bc764 100644 --- a/src/main/resources/application-common.yml +++ b/src/main/resources/application-common.yml @@ -9,4 +9,9 @@ spring: sql: init: mode: always -server.port: 8000 \ No newline at end of file + servlet: + multipart: + max-file-size: 100MB + max-request-size: 300MB +server.port: 8000 +spring.redirectUri: ${WEB_REDIRECT_URI} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 3318df98..cd28098a 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -10,10 +10,10 @@ spring: open-in-view: false show-sql: true hibernate: - ddl-auto: create + ddl-auto: update properties: hibernate: default_batch_fetch_size: 15 -server.port: 8000 +server.port: 8000 \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 02b4ad03..bc937c2c 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -1,18 +1,68 @@ spring: datasource: - url: jdbc:mysql://mysql.scg.skku.ac.kr:33306/s-top_dev + url: ${SPRING_DATASOURCE_URL} username: ${SPRING_DATASOURCE_USERNAME} password: ${SPRING_DATASOURCE_PASSWORD} jpa: open-in-view: false show-sql: true hibernate: - ddl-auto: create-drop + ddl-auto: none properties: hibernate: default_batch_fetch_size: 15 -# dialect: org.hibernate.dialect.MySQLDialect - database-platform: org.hibernate.dialect.MySQL8Dialect - - -server.port: 8000 \ No newline at end of file +--- +spring: + mail: + host: smtp.gmail.com + port: 587 + username: ${STOP_EMAIL} + password: ${STOP_EMAIL_PASSWORD} + properties: + mail: + smtp: + debug: true + auth: true + timeout: 5000 + starttls: + enable: true + adminEmail: ${ADMIN_EMAIL} +--- +minio: + url: ${MINIO_URL} + access: + key: scg + secret: + key: ${MINIO_SECRET} + bucket: + name: ${MINIO_BUCKET} +--- +spring: + notion: + secretKey: ${NOTION_SECRET} + version: 2022-06-28 + databaseId: + model: ${NOTION_DB_MODEL} + dataset: ${NOTION_DB_DATASET} + jobInfo: ${NOTION_DB_JOBINFO} +--- +spring: + security: + oauth2: + client: + registration: + kakao: + client-id: ${OAUTH_CLIENT_ID} + client-name: kakao-login + client-authentication-method: client_secret_post + redirect-uri: ${OAUTH_REDIRECT_URI} + authorization-grant-type: authorization_code + scope: + - profile_nickname + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id +server.port: 8000 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index bd58bc98..8d443b15 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,7 +1,7 @@ spring: - profiles: - default: local - group: - local: common - prod: common - + profiles: + default: local + group: + local: common, oauth, minio, notion, email + prod: common + dev: common, oauth, minio, notion, email diff --git a/src/main/resources/form/project_upload_form.xlsx b/src/main/resources/form/project_upload_form.xlsx new file mode 100644 index 00000000..178849af Binary files /dev/null and b/src/main/resources/form/project_upload_form.xlsx differ diff --git a/src/main/resources/static/docs/aihub-controller-test.html b/src/main/resources/static/docs/aihub-controller-test.html new file mode 100644 index 00000000..4270e63b --- /dev/null +++ b/src/main/resources/static/docs/aihub-controller-test.html @@ -0,0 +1,1213 @@ + + + + + + + +AI HUB API + + + + + +
+
+

AI HUB API

+
+
+
+

AI HUB 모델 리스트 조회 (POST /aihub/models)

+
+
+
+

Query parameters

+ +++++ + + + + + + + + + + + + + + + + + + + +
ParameterRequiredDescription

page

false

페이지 번호 [default: 0]

size

false

페이지 크기 [default: 10]

+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

AI 모델 제목

learningModels

Array

true

학습 모델

topics

Array

true

주제 분류

developmentYears

Array

true

개발 년도

professor

String

true

담당 교수

participants

Array

true

참여 학생

+
+
+

HTTP request

+
+
+
POST /aihub/models HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Content-Length: 199
+Host: localhost:8080
+
+{
+  "title" : "title",
+  "learningModels" : [ "학습 모델 1" ],
+  "topics" : [ "주제 1" ],
+  "developmentYears" : [ 2024 ],
+  "professor" : "담당 교수 1",
+  "participants" : [ "학생 1" ]
+}
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 1221
+
+{
+  "totalPages" : 1,
+  "totalElements" : 2,
+  "first" : true,
+  "last" : true,
+  "size" : 10,
+  "content" : [ {
+    "title" : "title",
+    "learningModels" : [ "학습 모델 1" ],
+    "topics" : [ "주제 1" ],
+    "developmentYears" : [ 2024 ],
+    "professor" : "담당 교수 1",
+    "participants" : [ "학생 1", "학생 2" ],
+    "object" : "page",
+    "id" : "노션 object 아이디",
+    "cover" : "커버 이미지 link",
+    "url" : "노션 redirect url"
+  }, {
+    "title" : "title",
+    "learningModels" : [ "학습 모델 1" ],
+    "topics" : [ "주제 1", "주제 2" ],
+    "developmentYears" : [ 2024 ],
+    "professor" : "담당 교수 1",
+    "participants" : [ "학생 1", "학생 2", "학생 3" ],
+    "object" : "page",
+    "id" : "노션 object 아이디",
+    "cover" : "커버 이미지 link",
+    "url" : "노션 redirect url"
+  } ],
+  "number" : 0,
+  "sort" : {
+    "empty" : true,
+    "sorted" : false,
+    "unsorted" : true
+  },
+  "numberOfElements" : 2,
+  "pageable" : {
+    "pageNumber" : 0,
+    "pageSize" : 10,
+    "sort" : {
+      "empty" : true,
+      "sorted" : false,
+      "unsorted" : true
+    },
+    "offset" : 0,
+    "paged" : true,
+    "unpaged" : false
+  },
+  "empty" : false
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

content[].object

String

true

object 종류

content[].id

String

true

노션 object 아이디

content[].cover

String

true

커버 이미지 link

content[].title

String

true

AI 모델 제목

content[].learningModels

Array

true

학습 모델

content[].topics

Array

true

주제 분류

content[].developmentYears

Array

true

개발 년도

content[].professor

String

true

담당 교수

content[].participants

Array

true

참여 학생

content[].url

String

true

노션 redirect url

pageable

Object

true

페이지 정보

pageable.pageNumber

Number

true

현재 페이지 번호

pageable.pageSize

Number

true

페이지 당 요소 수

pageable.sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

pageable.sort.sorted

Boolean

true

정렬된 상태인지 여부

pageable.sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

pageable.offset

Number

true

오프셋

pageable.paged

Boolean

true

페이징된 여부

pageable.unpaged

Boolean

true

페이징되지 않은 여부

totalElements

Number

true

전체 요소 수

totalPages

Number

true

전체 페이지 수

size

Number

true

페이지 당 요소 수

number

Number

true

현재 페이지 번호

numberOfElements

Number

true

현재 페이지 요소 수

first

Boolean

true

첫 페이지 여부

last

Boolean

true

마지막 페이지 여부

sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

sort.sorted

Boolean

true

정렬된 상태인지 여부

empty

Boolean

true

비어있는 페이지 여부

+
+
+
+
+
+

AI HUB 데이터셋 리스트 조회 (POST /aihub/datasets)

+
+
+
+

Query parameters

+ +++++ + + + + + + + + + + + + + + + + + + + +
ParameterRequiredDescription

page

false

페이지 번호 [default: 0]

size

false

페이지 크기 [default: 10]

+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

AI 데이터셋 제목

dataTypes

Array

true

데이터 유형

topics

Array

true

주제 분류

developmentYears

Array

true

구축 년도

professor

String

true

담당 교수

participants

Array

true

참여 학생

+
+
+

HTTP request

+
+
+
POST /aihub/datasets HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Content-Length: 197
+Host: localhost:8080
+
+{
+  "title" : "title",
+  "dataTypes" : [ "주제 1" ],
+  "topics" : [ "데이터 유형 1" ],
+  "developmentYears" : [ 2024 ],
+  "professor" : "담당 교수 1",
+  "participants" : [ "학생 1" ]
+}
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 1217
+
+{
+  "totalPages" : 1,
+  "totalElements" : 2,
+  "first" : true,
+  "last" : true,
+  "size" : 10,
+  "content" : [ {
+    "title" : "title",
+    "dataTypes" : [ "주제 1" ],
+    "topics" : [ "데이터 유형 1" ],
+    "developmentYears" : [ 2024 ],
+    "professor" : "담당 교수 1",
+    "participants" : [ "학생 1", "학생 2" ],
+    "object" : "page",
+    "id" : "노션 object 아이디",
+    "cover" : "커버 이미지 link",
+    "url" : "노션 redirect url"
+  }, {
+    "title" : "title",
+    "dataTypes" : [ "주제 1", "주제 2" ],
+    "topics" : [ "데이터 유형 1" ],
+    "developmentYears" : [ 2024 ],
+    "professor" : "담당 교수 1",
+    "participants" : [ "학생 1", "학생 2", "학생 3" ],
+    "object" : "page",
+    "id" : "노션 object 아이디",
+    "cover" : "커버 이미지 link",
+    "url" : "노션 redirect url"
+  } ],
+  "number" : 0,
+  "sort" : {
+    "empty" : true,
+    "sorted" : false,
+    "unsorted" : true
+  },
+  "numberOfElements" : 2,
+  "pageable" : {
+    "pageNumber" : 0,
+    "pageSize" : 10,
+    "sort" : {
+      "empty" : true,
+      "sorted" : false,
+      "unsorted" : true
+    },
+    "offset" : 0,
+    "paged" : true,
+    "unpaged" : false
+  },
+  "empty" : false
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

content[].object

String

true

object 종류

content[].id

String

true

노션 object 아이디

content[].cover

String

true

커버 이미지 link

content[].title

String

true

AI 데이터셋 제목

content[].topics

Array

true

주제 분류

content[].dataTypes

Array

true

데이터 유형

content[].developmentYears

Array

true

구축 년도

content[].professor

String

true

담당 교수

content[].participants

Array

true

참여 학생

content[].url

String

true

노션 redirect url

pageable

Object

true

페이지 정보

pageable.pageNumber

Number

true

현재 페이지 번호

pageable.pageSize

Number

true

페이지 당 요소 수

pageable.sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

pageable.sort.sorted

Boolean

true

정렬된 상태인지 여부

pageable.sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

pageable.offset

Number

true

오프셋

pageable.paged

Boolean

true

페이징된 여부

pageable.unpaged

Boolean

true

페이징되지 않은 여부

totalElements

Number

true

전체 요소 수

totalPages

Number

true

전체 페이지 수

size

Number

true

페이지 당 요소 수

number

Number

true

현재 페이지 번호

numberOfElements

Number

true

현재 페이지 요소 수

first

Boolean

true

첫 페이지 여부

last

Boolean

true

마지막 페이지 여부

sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

sort.sorted

Boolean

true

정렬된 상태인지 여부

empty

Boolean

true

비어있는 페이지 여부

+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/application.html b/src/main/resources/static/docs/application.html new file mode 100644 index 00000000..cad86b31 --- /dev/null +++ b/src/main/resources/static/docs/application.html @@ -0,0 +1,1084 @@ + + + + + + + +가입 신청 관리 API + + + + + +
+
+

가입 신청 관리 API

+
+
+
+

가입 신청 리스트 조회 (GET /applications)

+
+
+
+

HTTP request

+
+
+
GET /applications HTTP/1.1
+Authorization: admin_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Query parameters

+ +++++ + + + + + + + + + + + + + + + + + + + +
ParameterRequiredDescription

page

false

페이지 번호 [default: 0]

size

false

페이지 크기 [default: 10]

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 1178
+
+{
+  "totalPages" : 1,
+  "totalElements" : 3,
+  "first" : true,
+  "last" : true,
+  "size" : 10,
+  "content" : [ {
+    "id" : 1,
+    "name" : "김영한",
+    "division" : "배민",
+    "position" : null,
+    "userType" : "INACTIVE_COMPANY",
+    "createdAt" : "2024-11-28T19:49:21.620883",
+    "updatedAt" : "2024-11-28T19:49:21.620885"
+  }, {
+    "id" : 2,
+    "name" : "김교수",
+    "division" : "솦융대",
+    "position" : "교수",
+    "userType" : "INACTIVE_PROFESSOR",
+    "createdAt" : "2024-11-28T19:49:21.620894",
+    "updatedAt" : "2024-11-28T19:49:21.620895"
+  }, {
+    "id" : 3,
+    "name" : "박교수",
+    "division" : "정통대",
+    "position" : "교수",
+    "userType" : "INACTIVE_PROFESSOR",
+    "createdAt" : "2024-11-28T19:49:21.620897",
+    "updatedAt" : "2024-11-28T19:49:21.620897"
+  } ],
+  "number" : 0,
+  "sort" : {
+    "empty" : true,
+    "sorted" : false,
+    "unsorted" : true
+  },
+  "numberOfElements" : 3,
+  "pageable" : {
+    "pageNumber" : 0,
+    "pageSize" : 10,
+    "sort" : {
+      "empty" : true,
+      "sorted" : false,
+      "unsorted" : true
+    },
+    "offset" : 0,
+    "paged" : true,
+    "unpaged" : false
+  },
+  "empty" : false
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

pageable

Object

true

페이지 정보

pageable.pageNumber

Number

true

현재 페이지 번호

pageable.pageSize

Number

true

페이지 당 요소 수

pageable.sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

pageable.sort.sorted

Boolean

true

정렬된 상태인지 여부

pageable.sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

pageable.offset

Number

true

오프셋

pageable.paged

Boolean

true

페이징된 여부

pageable.unpaged

Boolean

true

페이징되지 않은 여부

totalElements

Number

true

전체 요소 수

totalPages

Number

true

전체 페이지 수

size

Number

true

페이지 당 요소 수

number

Number

true

현재 페이지 번호

numberOfElements

Number

true

현재 페이지 요소 수

first

Boolean

true

첫 페이지 여부

last

Boolean

true

마지막 페이지 여부

sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

sort.sorted

Boolean

true

정렬된 상태인지 여부

empty

Boolean

true

비어있는 페이지 여부

content[].id

Number

true

가입 신청 ID

content[].name

String

true

가입 신청자 이름

content[].division

String

false

소속

content[].position

String

false

직책

content[].userType

String

true

회원 유형 [INACTIVE_PROFESSOR, INACTIVE_COMPANY]

content[].createdAt

String

true

가입 신청 정보 생성일

content[].updatedAt

String

true

가입 신청 정보 수정일

+
+
+
+
+
+

가입 신청자 상세 정보 조회 (GET /applications/{applicationId})

+
+
+
+

HTTP request

+
+
+
GET /applications/1 HTTP/1.1
+Authorization: admin_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /applications/{applicationId}
ParameterDescription

applicationId

가입 신청 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 272
+
+{
+  "id" : 1,
+  "name" : "김영한",
+  "phone" : "010-1111-2222",
+  "email" : "email@gmail.com",
+  "division" : "배민",
+  "position" : "CEO",
+  "userType" : "INACTIVE_COMPANY",
+  "createdAt" : "2024-11-28T19:49:21.643274",
+  "updatedAt" : "2024-11-28T19:49:21.643275"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

가입 신청 ID

name

String

true

가입 신청자 이름

phone

String

true

가입 신청자 전화번호

email

String

true

가입 신청자 이메일

division

String

false

소속

position

String

false

직책

userType

String

true

회원 유형 [INACTIVE_PROFESSOR, INACTIVE_COMPANY]

createdAt

String

true

가입 신청 정보 생성일

updatedAt

String

true

가입 신청 정보 수정일

+
+
+
+
+
+

교수/기업 가입 허가 (PATCH /applications/{applicationId})

+
+
+
+

HTTP request

+
+
+
PATCH /applications/1 HTTP/1.1
+Authorization: admin_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /applications/{applicationId}
ParameterDescription

applicationId

가입 신청 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 262
+
+{
+  "id" : 1,
+  "name" : "김영한",
+  "phone" : "010-1111-2222",
+  "email" : "email@gmail.com",
+  "division" : "배민",
+  "position" : "CEO",
+  "userType" : "COMPANY",
+  "createdAt" : "2024-11-28T19:49:21.637588",
+  "updatedAt" : "2024-11-28T19:49:21.63759"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

가입 신청 ID

name

String

true

가입 신청자 이름

phone

String

true

가입 신청자 전화번호

email

String

true

가입 신청자 이메일

division

String

false

소속

position

String

false

직책

userType

String

true

회원 유형 [PROFESSOR, COMPANY]

createdAt

String

true

가입 신청 정보 생성일

updatedAt

String

true

가입 신청 정보 수정일

+
+
+
+
+
+

교수/기업 가입 거절 (DELETE /applications/{applicationId})

+
+
+
+

HTTP request

+
+
+
DELETE /applications/1 HTTP/1.1
+Authorization: admin_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /applications/{applicationId}
ParameterDescription

applicationId

가입 신청 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/auth-controller-test.html b/src/main/resources/static/docs/auth-controller-test.html new file mode 100644 index 00000000..8d5cce2f --- /dev/null +++ b/src/main/resources/static/docs/auth-controller-test.html @@ -0,0 +1,792 @@ + + + + + + + +인증 API + + + + + +
+
+

인증 API

+
+
+
+

카카오 소셜 로그인 (POST /auth/login/kakao/)

+
+
+
+

HTTP request

+
+
+
GET /auth/login/kakao?code=codefromkakaologin HTTP/1.1
+Host: localhost:8080
+
+
+
+
+

Query parameters

+ +++++ + + + + + + + + + + + + + + +
ParameterRequiredDescription

code

true

카카오 인가코드

+
+
+

HTTP response

+
+
+
HTTP/1.1 302 Found
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Set-Cookie: refresh-token=refresh_token; Path=/; Max-Age=604800; Expires=Thu, 5 Dec 2024 10:49:18 GMT; Secure; HttpOnly; SameSite=None
+Set-Cookie: access-token=access_token; Path=/; Max-Age=604800; Expires=Thu, 5 Dec 2024 10:49:18 GMT; Secure; SameSite=None
+Location: https://localhost:3000/login/kakao
+
+
+
+
+

Response fields

+
+

Snippet response-fields not found for operation::auth-controller-test/kakao-social-login

+
+
+
+
+
+
+

회원가입 (POST /auth/register)

+
+
+
+

HTTP request

+
+
+
POST /auth/register HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: access_token
+Content-Length: 289
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "name" : "stop-user",
+  "phoneNumber" : "010-1234-1234",
+  "userType" : "STUDENT",
+  "email" : "email@gmail.com",
+  "signUpSource" : "ad",
+  "studentInfo" : {
+    "department" : "소프트웨어학과",
+    "studentNumber" : "2021123123"
+  },
+  "division" : null,
+  "position" : null
+}
+
+
+
+
+

Request cookies

+ ++++ + + + + + + + + + + + + +
NameDescription

refresh-token

갱신 토큰

+
+
+

Request headers

+ ++++ + + + + + + + + + + + + +
NameDescription

Authorization

access token

+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

name

String

true

유저 이름

phoneNumber

String

true

전화 번호

userType

String

true

회원 유형

email

String

true

이메일

signUpSource

String

false

가입 경로

studentInfo.department

String

true

학과

studentInfo.studentNumber

String

true

학번

division

String

false

소속

position

String

false

직책

+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 86
+
+{
+  "name" : "stop-user",
+  "email" : "email@email.com",
+  "phone" : "010-1234-1234"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

name

String

true

이름

email

String

true

이메일

phone

String

true

전화번호

+
+
+
+
+
+

Access Token 재발급 (POST /auth/reissue)

+
+
+
+

HTTP request

+
+
+
POST /auth/reissue HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 45
+
+{
+  "accessToken" : "reissued_access_token"
+}
+
+
+
+
+
+
+
+

로그아웃 (POST /auth/logout)

+
+
+
+

HTTP request

+
+
+
POST /auth/logout HTTP/1.1
+Authorization: access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+Content-Type: application/x-www-form-urlencoded
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/department.html b/src/main/resources/static/docs/department.html new file mode 100644 index 00000000..4e79932b --- /dev/null +++ b/src/main/resources/static/docs/department.html @@ -0,0 +1,528 @@ + + + + + + + +학과 API + + + + + +
+
+

학과 API

+
+
+
+

학과 리스트 조회 (GET /departments)

+
+
+
+

HTTP request

+
+
+
GET /departments HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Host: localhost:8080
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 92
+
+[ {
+  "id" : 1,
+  "name" : "소프트웨어학과"
+}, {
+  "id" : 2,
+  "name" : "학과2"
+} ]
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

[].id

Number

true

학과 ID

[].name

String

true

학과 이름

+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/eventNotice.html b/src/main/resources/static/docs/eventNotice.html new file mode 100644 index 00000000..06120a85 --- /dev/null +++ b/src/main/resources/static/docs/eventNotice.html @@ -0,0 +1,1452 @@ + + + + + + + +이벤트 공지 사항 API + + + + + +
+
+

이벤트 공지 사항 API

+
+
+

이벤트 공지 사항 생성 (POST /eventNotices)

+
+
+
+

HTTP request

+
+
+
POST /eventNotices HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Content-Length: 141
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "title" : "이벤트 공지 사항 제목",
+  "content" : "이벤트 공지 사항 내용",
+  "fixed" : true,
+  "fileIds" : [ 1, 2, 3 ]
+}
+
+
+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

이벤트 공지 사항 제목

content

String

true

이벤트 공지 사항 내용

fixed

Boolean

true

이벤트 공지 사항 고정 여부

fileIds

Array

false

첨부 파일 ID 목록

+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 980
+
+{
+  "id" : 1,
+  "title" : "이벤트 공지 사항 제목",
+  "content" : "이벤트 공지 사항 내용",
+  "hitCount" : 0,
+  "fixed" : true,
+  "createdAt" : "2024-11-28T19:49:19.013422",
+  "updatedAt" : "2024-11-28T19:49:19.013423",
+  "files" : [ {
+    "id" : 1,
+    "uuid" : "014eb8a0-d4a6-11ee-adac-117d766aca1d",
+    "name" : "예시 첨부 파일 1.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:19.013406",
+    "updatedAt" : "2024-11-28T19:49:19.013412"
+  }, {
+    "id" : 2,
+    "uuid" : "11a480c0-13fa-11ef-9047-570191b390ea",
+    "name" : "예시 첨부 파일 2.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:19.013414",
+    "updatedAt" : "2024-11-28T19:49:19.013416"
+  }, {
+    "id" : 3,
+    "uuid" : "1883fc70-cfb4-11ee-a387-e754bd392d45",
+    "name" : "예시 첨부 파일 3.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:19.013418",
+    "updatedAt" : "2024-11-28T19:49:19.01342"
+  } ]
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

이벤트 공지 사항 ID

title

String

true

이벤트 공지 사항 제목

content

String

true

이벤트 공지 사항 내용

hitCount

Number

true

이벤트 공지 사항 조회수

fixed

Boolean

true

이벤트 공지 사항 고정 여부

createdAt

String

true

이벤트 공지 사항 생성일

updatedAt

String

true

이벤트 공지 사항 수정일

files

Array

true

첨부 파일 목록

files[].id

Number

true

파일 ID

files[].uuid

String

true

파일 고유 식별자

files[].name

String

true

파일 이름

files[].mimeType

String

true

파일 MIME 타입

files[].createdAt

String

true

파일 생성 시간

files[].updatedAt

String

true

파일 수정 시간

+
+
+
+
+
+

이벤트 공지 사항 리스트 조회 (GET /eventNotices)

+
+
+
+

Query parameters

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterRequiredDescription

terms

false

검색어 (optional)

scope

false

검색 범위 (title, content, both) [default: both, searchTerm=null 이면 null로 초기화]

page

false

페이지 번호 [default: 0]

size

false

페이지 크기 [default: 10]

+
+
+

HTTP request

+
+
+
GET /eventNotices?terms=notice&scope=title&page=0&size=10 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Host: localhost:8080
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 852
+
+{
+  "totalPages" : 1,
+  "totalElements" : 2,
+  "first" : true,
+  "last" : true,
+  "size" : 10,
+  "content" : [ {
+    "id" : 1,
+    "title" : "event notice 1",
+    "hitCount" : 10,
+    "fixed" : true,
+    "createdAt" : "2024-11-28T19:49:19.026853",
+    "updatedAt" : "2024-11-28T19:49:19.026858"
+  }, {
+    "id" : 2,
+    "title" : "event notice 2",
+    "hitCount" : 10,
+    "fixed" : false,
+    "createdAt" : "2024-11-28T19:49:19.026866",
+    "updatedAt" : "2024-11-28T19:49:19.026867"
+  } ],
+  "number" : 0,
+  "sort" : {
+    "empty" : true,
+    "sorted" : false,
+    "unsorted" : true
+  },
+  "numberOfElements" : 2,
+  "pageable" : {
+    "pageNumber" : 0,
+    "pageSize" : 10,
+    "sort" : {
+      "empty" : true,
+      "sorted" : false,
+      "unsorted" : true
+    },
+    "offset" : 0,
+    "paged" : true,
+    "unpaged" : false
+  },
+  "empty" : false
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

content[].id

Number

true

이벤트 공지 사항 ID

content[].title

String

true

이벤트 공지 사항 제목

content[].hitCount

Number

true

이벤트 공지 사항 조회수

content[].fixed

Boolean

true

이벤트 공지 사항 고정 여부

content[].createdAt

String

true

이벤트 공지 사항 생성일

content[].updatedAt

String

true

이벤트 공지 사항 수정일

pageable

Object

true

페이지 정보

pageable.pageNumber

Number

true

현재 페이지 번호

pageable.pageSize

Number

true

페이지 당 요소 수

pageable.sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

pageable.sort.sorted

Boolean

true

정렬된 상태인지 여부

pageable.sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

pageable.offset

Number

true

오프셋

pageable.paged

Boolean

true

페이징된 여부

pageable.unpaged

Boolean

true

페이징되지 않은 여부

totalElements

Number

true

전체 요소 수

totalPages

Number

true

전체 페이지 수

size

Number

true

페이지 당 요소 수

number

Number

true

현재 페이지 번호

numberOfElements

Number

true

현재 페이지 요소 수

first

Boolean

true

첫 페이지 여부

last

Boolean

true

마지막 페이지 여부

sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

sort.sorted

Boolean

true

정렬된 상태인지 여부

empty

Boolean

true

비어있는 페이지 여부

+
+
+
+
+
+

이벤트 공지 사항 조회 (GET /eventNotices/{eventNoticeId})

+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /eventNotices/{eventNoticeId}
ParameterDescription

eventNoticeId

조회할 이벤트 공지 사항 ID

+
+
+

HTTP request

+
+
+
GET /eventNotices/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Host: localhost:8080
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 958
+
+{
+  "id" : 1,
+  "title" : "이벤트 공지 사항 제목",
+  "content" : "content",
+  "hitCount" : 10,
+  "fixed" : true,
+  "createdAt" : "2024-11-28T19:49:18.99268",
+  "updatedAt" : "2024-11-28T19:49:18.992682",
+  "files" : [ {
+    "id" : 1,
+    "uuid" : "014eb8a0-d4a6-11ee-adac-117d766aca1d",
+    "name" : "예시 첨부 파일 1.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:18.992663",
+    "updatedAt" : "2024-11-28T19:49:18.992668"
+  }, {
+    "id" : 2,
+    "uuid" : "11a480c0-13fa-11ef-9047-570191b390ea",
+    "name" : "예시 첨부 파일 2.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:18.992671",
+    "updatedAt" : "2024-11-28T19:49:18.992673"
+  }, {
+    "id" : 3,
+    "uuid" : "1883fc70-cfb4-11ee-a387-e754bd392d45",
+    "name" : "예시 첨부 파일 3.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:18.992674",
+    "updatedAt" : "2024-11-28T19:49:18.992677"
+  } ]
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

이벤트 공지 사항 ID

title

String

true

이벤트 공지 사항 제목

content

String

true

이벤트 공지 사항 내용

hitCount

Number

true

이벤트 공지 사항 조회수

fixed

Boolean

true

이벤트 공지 사항 고정 여부

createdAt

String

true

이벤트 공지 사항 생성일

updatedAt

String

true

이벤트 공지 사항 수정일

files

Array

true

첨부 파일 목록

files[].id

Number

true

파일 ID

files[].uuid

String

true

파일 고유 식별자

files[].name

String

true

파일 이름

files[].mimeType

String

true

파일 MIME 타입

files[].createdAt

String

true

파일 생성 시간

files[].updatedAt

String

true

파일 수정 시간

+
+
+
+
+
+

이벤트 공지 사항 수정 (PUT /eventNotices/{eventNoticeId})

+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /eventNotices/{eventNoticeId}
ParameterDescription

eventNoticeId

수정할 이벤트 공지 사항 ID

+
+
+

HTTP request

+
+
+
PUT /eventNotices/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Content-Length: 162
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "title" : "수정된 이벤트 공지 사항 제목",
+  "content" : "수정된 이벤트 공지 사항 내용",
+  "fixed" : false,
+  "fileIds" : [ 1, 2, 3 ]
+}
+
+
+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

이벤트 공지 사항 제목

content

String

true

이벤트 공지 사항 내용

fixed

Boolean

true

이벤트 공지 사항 고정 여부

fileIds

Array

false

첨부 파일 ID 목록

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 975
+
+{
+  "id" : 1,
+  "title" : "수정된 이벤트 공지 사항 제목",
+  "content" : "수정된 이벤트 공지 사항 내용",
+  "hitCount" : 10,
+  "fixed" : false,
+  "createdAt" : "2024-01-01T12:00:00",
+  "updatedAt" : "2024-11-28T19:49:18.969893",
+  "files" : [ {
+    "id" : 1,
+    "uuid" : "014eb8a0-d4a6-11ee-adac-117d766aca1d",
+    "name" : "예시 첨부 파일 1.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-01-01T12:00:00",
+    "updatedAt" : "2024-11-28T19:49:18.969851"
+  }, {
+    "id" : 2,
+    "uuid" : "11a480c0-13fa-11ef-9047-570191b390ea",
+    "name" : "예시 첨부 파일 2.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-01-01T12:00:00",
+    "updatedAt" : "2024-11-28T19:49:18.969863"
+  }, {
+    "id" : 3,
+    "uuid" : "1883fc70-cfb4-11ee-a387-e754bd392d45",
+    "name" : "예시 첨부 파일 3.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-01-01T12:00:00",
+    "updatedAt" : "2024-11-28T19:49:18.969868"
+  } ]
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

이벤트 공지 사항 ID

title

String

true

이벤트 공지 사항 제목

content

String

true

이벤트 공지 사항 내용

hitCount

Number

true

이벤트 공지 사항 조회수

fixed

Boolean

true

이벤트 공지 사항 고정 여부

createdAt

String

true

이벤트 공지 사항 생성일

updatedAt

String

true

이벤트 공지 사항 수정일

files

Array

true

첨부 파일 목록

files[].id

Number

true

파일 ID

files[].uuid

String

true

파일 고유 식별자

files[].name

String

true

파일 이름

files[].mimeType

String

true

파일 MIME 타입

files[].createdAt

String

true

파일 생성 시간

files[].updatedAt

String

true

파일 수정 시간

+
+
+
+
+
+

이벤트 공지 사항 삭제 (DELETE /eventNotices/{eventNoticeId})

+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /eventNotices/{eventNoticeId}
ParameterDescription

eventNoticeId

삭제할 이벤트 공지 사항 ID

+
+
+

HTTP request

+
+
+
DELETE /eventNotices/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/eventPeriod.html b/src/main/resources/static/docs/eventPeriod.html new file mode 100644 index 00000000..96bdb06b --- /dev/null +++ b/src/main/resources/static/docs/eventPeriod.html @@ -0,0 +1,932 @@ + + + + + + + +이벤트 기간 API + + + + + +
+
+

이벤트 기간 API

+
+
+
+

이벤트 기간 생성 (POST /eventPeriods)

+
+
+
+

HTTP request

+
+
+
POST /eventPeriods HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Content-Length: 84
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "start" : "2024-11-28T19:49:19.262199",
+  "end" : "2024-12-08T19:49:19.262203"
+}
+
+
+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

start

String

true

이벤트 시작 일시

end

String

true

이벤트 종료 일시

+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 204
+
+{
+  "id" : 1,
+  "year" : 2024,
+  "start" : "2024-11-28T19:49:19.262206",
+  "end" : "2024-12-08T19:49:19.262208",
+  "createdAt" : "2024-11-28T19:49:19.26221",
+  "updatedAt" : "2024-11-28T19:49:19.262211"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

이벤트 기간 ID

year

Number

true

이벤트 연도

start

String

true

이벤트 시작 일시

end

String

true

이벤트 종료 일시

createdAt

String

true

생성일

updatedAt

String

true

변경일

+
+
+
+
+
+

올해의 이벤트 기간 조회 (GET /eventPeriod)

+
+
+
+

HTTP request

+
+
+
GET /eventPeriod HTTP/1.1
+Authorization: admin_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 205
+
+{
+  "id" : 1,
+  "year" : 2024,
+  "start" : "2024-11-28T19:49:19.254084",
+  "end" : "2024-12-08T19:49:19.254086",
+  "createdAt" : "2024-11-28T19:49:19.254089",
+  "updatedAt" : "2024-11-28T19:49:19.254091"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

이벤트 기간 ID

year

Number

true

이벤트 연도

start

String

true

이벤트 시작 일시

end

String

true

이벤트 종료 일시

createdAt

String

true

생성일

updatedAt

String

true

변경일

+
+
+
+
+
+

전체 이벤트 기간 리스트 조회 (GET /eventPeriods)

+
+
+
+

HTTP request

+
+
+
GET /eventPeriods HTTP/1.1
+Authorization: admin_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 416
+
+[ {
+  "id" : 1,
+  "year" : 2024,
+  "start" : "2024-11-28T19:49:19.278318",
+  "end" : "2024-12-08T19:49:19.278322",
+  "createdAt" : "2024-11-28T19:49:19.278325",
+  "updatedAt" : "2024-11-28T19:49:19.278331"
+}, {
+  "id" : 2,
+  "year" : 2025,
+  "start" : "2024-11-28T19:49:19.278333",
+  "end" : "2024-12-08T19:49:19.278334",
+  "createdAt" : "2024-11-28T19:49:19.278336",
+  "updatedAt" : "2024-11-28T19:49:19.278338"
+} ]
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

[].id

Number

true

이벤트 기간 ID

[].year

Number

true

이벤트 연도

[].start

String

true

이벤트 시작 일시

[].end

String

true

이벤트 종료 일시

[].createdAt

String

true

생성일

[].updatedAt

String

true

변경일

+
+
+
+
+
+

올해의 이벤트 기간 업데이트 (PUT /eventPeriod)

+
+
+
+

HTTP request

+
+
+
PUT /eventPeriod HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Content-Length: 83
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "start" : "2024-11-28T19:49:19.23885",
+  "end" : "2024-12-08T19:49:19.238855"
+}
+
+
+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

start

String

true

이벤트 시작 일시

end

String

true

이벤트 종료 일시

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 205
+
+{
+  "id" : 1,
+  "year" : 2024,
+  "start" : "2024-11-28T19:49:19.238886",
+  "end" : "2024-12-08T19:49:19.238887",
+  "createdAt" : "2024-11-28T19:49:19.238889",
+  "updatedAt" : "2024-11-28T19:49:19.238891"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

이벤트 기간 ID

year

Number

true

이벤트 연도

start

String

true

이벤트 시작 일시

end

String

true

이벤트 종료 일시

createdAt

String

true

생성일

updatedAt

String

true

변경일

+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/file-controller-test.html b/src/main/resources/static/docs/file-controller-test.html new file mode 100644 index 00000000..31d319f5 --- /dev/null +++ b/src/main/resources/static/docs/file-controller-test.html @@ -0,0 +1,680 @@ + + + + + + + +파일 API + + + + + +
+
+

파일 API

+
+
+
+

다중 파일 업로드 (POST /files)

+
+
+
+

HTTP request

+
+
+
POST /files HTTP/1.1
+Content-Type: multipart/form-data;charset=UTF-8; boundary=6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Host: localhost:8080
+
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Disposition: form-data; name=files; filename=첨부파일1.png
+Content-Type: image/png
+
+[BINARY DATA - PNG IMAGE CONTENT]
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Disposition: form-data; name=files; filename=첨부파일2.pdf
+Content-Type: application/pdf
+
+[BINARY DATA - PDF CONTENT]
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm--
+
+
+
+
+

Request parts

+ ++++ + + + + + + + + + + + + +
PartDescription

files

업로드할 파일 리스트

+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 446
+
+[ {
+  "id" : 1,
+  "uuid" : "8cedc1bd-ce93-42f9-a8dc-2ab77db48c2b",
+  "name" : "첨부파일1.png",
+  "mimeType" : "image/png",
+  "createdAt" : "2024-11-28T19:49:19.746397",
+  "updatedAt" : "2024-11-28T19:49:19.746401"
+}, {
+  "id" : 2,
+  "uuid" : "b5733f48-f03b-401a-87e2-0e13a474bb12",
+  "name" : "첨부파일2.pdf",
+  "mimeType" : "application/pdf",
+  "createdAt" : "2024-11-28T19:49:19.746425",
+  "updatedAt" : "2024-11-28T19:49:19.746427"
+} ]
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

[].id

Number

true

파일 ID

[].uuid

String

true

파일 UUID

[].name

String

true

파일 이름

[].mimeType

String

true

파일의 MIME 타입

[].createdAt

String

true

파일 생성일

[].updatedAt

String

true

파일 수정일

+
+
+
+
+
+

파일 조회 (GET /files/{fileId})

+
+
+
+

HTTP request

+
+
+
GET /files/1 HTTP/1.1
+Host: localhost:8080
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /files/{fileId}
ParameterDescription

fileId

파일 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: image/png;charset=UTF-8
+Content-Length: 33
+
+[BINARY DATA - PNG IMAGE CONTENT]
+
+
+
+
+
+
+
+

프로젝트 일괄 등록 양식 다운로드 (GET /files/form/projects)

+
+
+
+

HTTP request

+
+
+
GET /files/form/projects HTTP/1.1
+Authorization: admin_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Disposition: form-data; name="attachment"; filename="project_upload_form.xlsx"
+Content-Type: application/octet-stream;charset=UTF-8
+Content-Length: 19
+
+project_upload_form
+
+
+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/gallery.html b/src/main/resources/static/docs/gallery.html new file mode 100644 index 00000000..8a7f9d2c --- /dev/null +++ b/src/main/resources/static/docs/gallery.html @@ -0,0 +1,1446 @@ + + + + + + + +갤러리 API + + + + + +
+
+

갤러리 API

+
+
+
+

갤러리 게시글 생성 (POST /galleries)

+
+
+
+

HTTP request

+
+
+
POST /galleries HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Content-Length: 96
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "title" : "새내기 배움터",
+  "year" : 2024,
+  "month" : 4,
+  "fileIds" : [ 1, 2, 3 ]
+}
+
+
+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

제목

year

Number

true

연도

month

Number

true

fileIds

Array

true

파일 ID 리스트

+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 890
+
+{
+  "id" : 1,
+  "title" : "새내기 배움터",
+  "year" : 2024,
+  "month" : 4,
+  "hitCount" : 1,
+  "createdAt" : "2024-11-28T19:49:20.00415",
+  "updatedAt" : "2024-11-28T19:49:20.004152",
+  "files" : [ {
+    "id" : 1,
+    "uuid" : "014eb8a0-d4a6-11ee-adac-117d766aca1d",
+    "name" : "사진1.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:20.004133",
+    "updatedAt" : "2024-11-28T19:49:20.004137"
+  }, {
+    "id" : 2,
+    "uuid" : "11a480c0-13fa-11ef-9047-570191b390ea",
+    "name" : "사진2.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:20.004139",
+    "updatedAt" : "2024-11-28T19:49:20.004141"
+  }, {
+    "id" : 3,
+    "uuid" : "1883fc70-cfb4-11ee-a387-e754bd392d45",
+    "name" : "사진3.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:20.004142",
+    "updatedAt" : "2024-11-28T19:49:20.004144"
+  } ]
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

갤러리 ID

title

String

true

제목

year

Number

true

연도

month

Number

true

hitCount

Number

true

조회수

createdAt

String

true

생성일

updatedAt

String

true

수정일

files

Array

true

파일 목록

files[].id

Number

true

파일 ID

files[].uuid

String

true

파일 UUID

files[].name

String

true

파일 이름

files[].mimeType

String

true

파일 MIME 타입

files[].createdAt

String

true

파일 생성일

files[].updatedAt

String

true

파일 수정일

+
+
+
+
+
+

갤러리 게시글 목록 조회 (GET /galleries)

+
+
+
+

HTTP request

+
+
+
GET /galleries?year=2024&month=4 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Host: localhost:8080
+
+
+
+
+

Query parameters

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterRequiredDescription

year

false

연도

month

false

page

false

페이지 번호 [default: 0]

size

false

페이지 크기 [default: 10]

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 1234
+
+{
+  "totalPages" : 1,
+  "totalElements" : 1,
+  "first" : true,
+  "last" : true,
+  "size" : 1,
+  "content" : [ {
+    "id" : 1,
+    "title" : "새내기 배움터",
+    "year" : 2024,
+    "month" : 4,
+    "hitCount" : 0,
+    "createdAt" : "2024-11-28T19:49:19.979402",
+    "updatedAt" : "2024-11-28T19:49:19.979404",
+    "files" : [ {
+      "id" : 1,
+      "uuid" : "014eb8a0-d4a6-11ee-adac-117d766aca1d",
+      "name" : "사진1.jpg",
+      "mimeType" : "image/jpeg",
+      "createdAt" : "2024-11-28T19:49:19.979386",
+      "updatedAt" : "2024-11-28T19:49:19.97939"
+    }, {
+      "id" : 2,
+      "uuid" : "11a480c0-13fa-11ef-9047-570191b390ea",
+      "name" : "사진2.jpg",
+      "mimeType" : "image/jpeg",
+      "createdAt" : "2024-11-28T19:49:19.979392",
+      "updatedAt" : "2024-11-28T19:49:19.979394"
+    }, {
+      "id" : 3,
+      "uuid" : "1883fc70-cfb4-11ee-a387-e754bd392d45",
+      "name" : "사진3.jpg",
+      "mimeType" : "image/jpeg",
+      "createdAt" : "2024-11-28T19:49:19.979396",
+      "updatedAt" : "2024-11-28T19:49:19.979397"
+    } ]
+  } ],
+  "number" : 0,
+  "sort" : {
+    "empty" : true,
+    "sorted" : false,
+    "unsorted" : true
+  },
+  "numberOfElements" : 1,
+  "pageable" : "INSTANCE",
+  "empty" : false
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

totalElements

Number

true

전체 데이터 수

totalPages

Number

true

전체 페이지 수

size

Number

true

페이지당 데이터 수

content

Array

true

갤러리 목록

content[].id

Number

true

갤러리 ID

content[].title

String

true

갤러리 제목

content[].year

Number

true

갤러리 연도

content[].month

Number

true

갤러리 월

content[].hitCount

Number

true

갤러리 조회수

content[].createdAt

String

true

갤러리 생성일

content[].updatedAt

String

true

갤러리 수정일

content[].files

Array

true

파일 목록

content[].files[].id

Number

true

파일 ID

content[].files[].uuid

String

true

파일 UUID

content[].files[].name

String

true

파일 이름

content[].files[].mimeType

String

true

파일 MIME 타입

content[].files[].createdAt

String

true

파일 생성일

content[].files[].updatedAt

String

true

파일 수정일

number

Number

true

현재 페이지 번호

sort.empty

Boolean

true

정렬 정보

sort.sorted

Boolean

true

정렬 정보

sort.unsorted

Boolean

true

정렬 정보

first

Boolean

true

첫 페이지 여부

last

Boolean

true

마지막 페이지 여부

numberOfElements

Number

true

현재 페이지의 데이터 수

empty

Boolean

true

빈 페이지 여부

+
+
+
+
+
+

갤러리 게시글 조회 (GET /galleries/{galleryId})

+
+
+
+

HTTP request

+
+
+
GET /galleries/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Host: localhost:8080
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /galleries/{galleryId}
ParameterDescription

galleryId

조회할 갤러리 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 890
+
+{
+  "id" : 1,
+  "title" : "새내기 배움터",
+  "year" : 2024,
+  "month" : 4,
+  "hitCount" : 1,
+  "createdAt" : "2024-11-28T19:49:19.995362",
+  "updatedAt" : "2024-11-28T19:49:19.995363",
+  "files" : [ {
+    "id" : 1,
+    "uuid" : "014eb8a0-d4a6-11ee-adac-117d766aca1d",
+    "name" : "사진1.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:19.995349",
+    "updatedAt" : "2024-11-28T19:49:19.995353"
+  }, {
+    "id" : 2,
+    "uuid" : "11a480c0-13fa-11ef-9047-570191b390ea",
+    "name" : "사진2.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:19.995355",
+    "updatedAt" : "2024-11-28T19:49:19.995357"
+  }, {
+    "id" : 3,
+    "uuid" : "1883fc70-cfb4-11ee-a387-e754bd392d45",
+    "name" : "사진3.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:19.995358",
+    "updatedAt" : "2024-11-28T19:49:19.99536"
+  } ]
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

갤러리 ID

title

String

true

제목

year

Number

true

연도

month

Number

true

hitCount

Number

true

조회수

createdAt

String

true

생성일

updatedAt

String

true

수정일

files

Array

true

파일 목록

files[].id

Number

true

파일 ID

files[].uuid

String

true

파일 UUID

files[].name

String

true

파일 이름

files[].mimeType

String

true

파일 MIME 타입

files[].createdAt

String

true

파일 생성일

files[].updatedAt

String

true

파일 수정일

+
+
+
+
+
+

갤러리 게시글 삭제 (DELETE /galleries/{galleryId})

+
+
+
+

HTTP request

+
+
+
DELETE /galleries/1 HTTP/1.1
+Authorization: admin_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /galleries/{galleryId}
ParameterDescription

galleryId

삭제할 갤러리 ID

+
+
+
+
+
+

갤러리 게시글 수정 (PUT /galleries/{galleryId})

+
+
+
+

HTTP request

+
+
+
PUT /galleries/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Content-Length: 93
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "title" : "수정된 제목",
+  "year" : 2024,
+  "month" : 5,
+  "fileIds" : [ 1, 2, 3 ]
+}
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /galleries/{galleryId}
ParameterDescription

galleryId

수정할 갤러리 ID

+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

제목

year

Number

true

연도

month

Number

true

fileIds

Array

true

파일 ID 리스트

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 885
+
+{
+  "id" : 1,
+  "title" : "수정된 제목",
+  "year" : 2024,
+  "month" : 5,
+  "hitCount" : 1,
+  "createdAt" : "2024-11-28T19:49:19.953599",
+  "updatedAt" : "2024-11-28T19:49:19.9536",
+  "files" : [ {
+    "id" : 1,
+    "uuid" : "014eb8a0-d4a6-11ee-adac-117d766aca1d",
+    "name" : "사진1.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:19.95354",
+    "updatedAt" : "2024-11-28T19:49:19.953546"
+  }, {
+    "id" : 2,
+    "uuid" : "11a480c0-13fa-11ef-9047-570191b390ea",
+    "name" : "사진2.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:19.953552",
+    "updatedAt" : "2024-11-28T19:49:19.953553"
+  }, {
+    "id" : 3,
+    "uuid" : "1883fc70-cfb4-11ee-a387-e754bd392d45",
+    "name" : "사진3.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:19.953556",
+    "updatedAt" : "2024-11-28T19:49:19.953557"
+  } ]
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

갤러리 ID

title

String

true

제목

year

Number

true

연도

month

Number

true

hitCount

Number

true

조회수

createdAt

String

true

생성일

updatedAt

String

true

수정일

files

Array

true

파일 목록

files[].id

Number

true

파일 ID

files[].uuid

String

true

파일 UUID

files[].name

String

true

파일 이름

files[].mimeType

String

true

파일 MIME 타입

files[].createdAt

String

true

파일 생성일

files[].updatedAt

String

true

파일 수정일

+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/index.html b/src/main/resources/static/docs/index.html new file mode 100644 index 00000000..c596bc3b --- /dev/null +++ b/src/main/resources/static/docs/index.html @@ -0,0 +1,16156 @@ + + + + + + + +S-TOP Rest Docs + + + + + + +
+
+

커스텀 예외 코드

+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CodeMessage

1000

요청 형식이 올바르지 않습니다.

1001

해당 연도의 행사 기간이 이미 존재합니다.

1002

올해의 이벤트 기간이 존재하지 않습니다.

1003

이벤트 시작 일시 혹은 종료 일시가 올해를 벗어났습니다.

2001

소셜 로그인 공급자로부터 유저 정보를 받아올 수 없습니다.

2002

소셜 로그인 공급자로부터 인증 토큰을 받아올 수 없습니다.

3000

접근할 수 없는 리소스입니다.

3001

유효하지 않은 Refresh Token 입니다.

3002

토큰 검증에 실패했습니다.

3003

유효하지 않은 Access Token 입니다.

13000

Notion 데이터를 가져오는데 실패했습니다.

4000

유저 id 를 찾을 수 없습니다.

4001

회원가입이 필요합니다.

4002

유저 권한이 존재하지 않습니다.

4003

학과가 존재하지 않습니다.

4004

학과/학번 정보가 존재하지 않습니다.

4005

회원 가입 이용이 불가능한 회원 유형입니다.

4010

ID에 해당하는 인증 신청 정보가 존재하지 않습니다.

4011

이미 인증 된 회원입니다.

4100

회원 유형은 수정할 수 없습니다.

4101

소속 또는 직책의 형식이 잘못되었습니다.

77000

프로젝트를 찾을 수 없습니다.

77001

프로젝트 썸네일을 찾을 수 없습니다

77002

프로젝트 포스터를 찾을 수 없습니다

77003

멤버 정보가 올바르지 않습니다.

77004

기술 스택 정보가 올바르지 않습니다.

77005

관심 표시한 프로젝트가 이미 존재합니다

77007

관심 표시한 프로젝트를 찾을 수 없습니다.

77008

이미 좋아요 한 프로젝트입니다.

77009

좋아요 표시한 프로젝트가 존재하지 않습니다

77010

댓글을 찾을 수 없습니다

77011

유저 정보가 일치하지 않습니다

78000

엑셀 행의 개수와 업로드한 이미지의 개수가 일치해야합니다.

78001

썸네일 이미지 이름을 찾을 수 없습니다.

78002

포스터 이미지 이름을 찾을 수 없습니다.

78003

수상 내역의 한글 이름이 올바르지 않습니다.

78004

프로젝트 종류의 한글 이름이 올바르지 않습니다.

78005

프로젝트 분야의 한글 이름이 올바르지 않습니다.

78006

모든 셀은 값이 있어야 합니다.

78007

엑셀 형식이 올바르지 않습니다.

78008

중복된 썸네일 이미지 이름이 존재합니다.

78009

중복된 포스터 이미지 이름이 존재합니다.

780010

프로젝트 년도는 숫자만 입력해야 합니다.

780011

엑셀 파일을 열 수 없습니다.

5000

파일 업로드를 실패했습니다.

5001

파일 가져오기를 실패했습니다.

5002

요청한 ID에 해당하는 파일이 존재하지 않습니다.

5002

요청한 ID에 해당하는 파일이 존재하지 않습니다.

5004

파일을 찾을 수 없습니다.

10000

요청한 ID에 해당하는 공지사항이 존재하지 않습니다.

11000

요청한 ID에 해당하는 이벤트가 존재하지 않습니다.

8000

요청한 ID에 해당하는 문의가 존재하지 않습니다.

8001

요청한 ID에 해당하는 문의 답변이 존재하지 않습니다.

8002

이미 답변이 등록된 문의입니다.

8003

해당 문의에 대한 권한이 없습니다.

8200

해당 ID에 해당하는 잡페어 인터뷰가 없습니다.

8400

해당 ID에 해당하는 대담 영상이 없습니다.

8401

퀴즈 데이터가 존재하지 않습니다.

8402

퀴즈 제출 데이터가 존재하지 않습니다.

8601

이미 퀴즈의 정답을 모두 맞추었습니다.

8801

이미 관심 리스트에 추가되었습니다.

8802

이미 관심 리스트에 추가되어 있지 않습니다.

8804

퀴즈 이벤트 참여 기간이 아닙니다.

8805

대담 영상과 현재 이벤트 참여 연도가 일치하지 않습니다.

8901

퀴즈 최대 시도 횟수를 초과하였습니다.

71001

엑셀 파일이 주어진 클래스와 호환되지 않습니다.

9001

요청한 ID에 해당하는 갤러리가 존재하지 않습니다.

6000

요청한 ID에 해당하는 과제제안이 존재하지 않습니다.

6001

요청한 ID에 해당하는 과제제안 답변이 존재하지 않습니다.

+
+
+

인증 API

+
+
+
+

카카오 소셜 로그인 (POST /auth/login/kakao/)

+
+
+
+

HTTP request

+
+
+
GET /auth/login/kakao?code=codefromkakaologin HTTP/1.1
+Host: localhost:8080
+
+
+
+
+

Query parameters

+ +++++ + + + + + + + + + + + + + + +
ParameterRequiredDescription

code

true

카카오 인가코드

+
+
+

HTTP response

+
+
+
HTTP/1.1 302 Found
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Set-Cookie: refresh-token=refresh_token; Path=/; Max-Age=604800; Expires=Thu, 5 Dec 2024 10:49:18 GMT; Secure; HttpOnly; SameSite=None
+Set-Cookie: access-token=access_token; Path=/; Max-Age=604800; Expires=Thu, 5 Dec 2024 10:49:18 GMT; Secure; SameSite=None
+Location: https://localhost:3000/login/kakao
+
+
+
+
+

Response fields

+
+

Snippet response-fields not found for operation::auth-controller-test/kakao-social-login

+
+
+
+
+
+
+

회원가입 (POST /auth/register)

+
+
+
+

HTTP request

+
+
+
POST /auth/register HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: access_token
+Content-Length: 289
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "name" : "stop-user",
+  "phoneNumber" : "010-1234-1234",
+  "userType" : "STUDENT",
+  "email" : "email@gmail.com",
+  "signUpSource" : "ad",
+  "studentInfo" : {
+    "department" : "소프트웨어학과",
+    "studentNumber" : "2021123123"
+  },
+  "division" : null,
+  "position" : null
+}
+
+
+
+
+

Request cookies

+ ++++ + + + + + + + + + + + + +
NameDescription

refresh-token

갱신 토큰

+
+
+

Request headers

+ ++++ + + + + + + + + + + + + +
NameDescription

Authorization

access token

+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

name

String

true

유저 이름

phoneNumber

String

true

전화 번호

userType

String

true

회원 유형

email

String

true

이메일

signUpSource

String

false

가입 경로

studentInfo.department

String

true

학과

studentInfo.studentNumber

String

true

학번

division

String

false

소속

position

String

false

직책

+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 86
+
+{
+  "name" : "stop-user",
+  "email" : "email@email.com",
+  "phone" : "010-1234-1234"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

name

String

true

이름

email

String

true

이메일

phone

String

true

전화번호

+
+
+
+
+
+

Access Token 재발급 (POST /auth/reissue)

+
+
+
+

HTTP request

+
+
+
POST /auth/reissue HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 45
+
+{
+  "accessToken" : "reissued_access_token"
+}
+
+
+
+
+
+
+
+

로그아웃 (POST /auth/logout)

+
+
+
+

HTTP request

+
+
+
POST /auth/logout HTTP/1.1
+Authorization: access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+Content-Type: application/x-www-form-urlencoded
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+
+
+
+

파일 API

+
+
+
+

다중 파일 업로드 (POST /files)

+
+
+
+

HTTP request

+
+
+
POST /files HTTP/1.1
+Content-Type: multipart/form-data;charset=UTF-8; boundary=6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Host: localhost:8080
+
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Disposition: form-data; name=files; filename=첨부파일1.png
+Content-Type: image/png
+
+[BINARY DATA - PNG IMAGE CONTENT]
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Disposition: form-data; name=files; filename=첨부파일2.pdf
+Content-Type: application/pdf
+
+[BINARY DATA - PDF CONTENT]
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm--
+
+
+
+
+

Request parts

+ ++++ + + + + + + + + + + + + +
PartDescription

files

업로드할 파일 리스트

+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 446
+
+[ {
+  "id" : 1,
+  "uuid" : "8cedc1bd-ce93-42f9-a8dc-2ab77db48c2b",
+  "name" : "첨부파일1.png",
+  "mimeType" : "image/png",
+  "createdAt" : "2024-11-28T19:49:19.746397",
+  "updatedAt" : "2024-11-28T19:49:19.746401"
+}, {
+  "id" : 2,
+  "uuid" : "b5733f48-f03b-401a-87e2-0e13a474bb12",
+  "name" : "첨부파일2.pdf",
+  "mimeType" : "application/pdf",
+  "createdAt" : "2024-11-28T19:49:19.746425",
+  "updatedAt" : "2024-11-28T19:49:19.746427"
+} ]
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

[].id

Number

true

파일 ID

[].uuid

String

true

파일 UUID

[].name

String

true

파일 이름

[].mimeType

String

true

파일의 MIME 타입

[].createdAt

String

true

파일 생성일

[].updatedAt

String

true

파일 수정일

+
+
+
+
+
+

파일 조회 (GET /files/{fileId})

+
+
+
+

HTTP request

+
+
+
GET /files/1 HTTP/1.1
+Host: localhost:8080
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /files/{fileId}
ParameterDescription

fileId

파일 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: image/png;charset=UTF-8
+Content-Length: 33
+
+[BINARY DATA - PNG IMAGE CONTENT]
+
+
+
+
+
+
+
+

프로젝트 일괄 등록 양식 다운로드 (GET /files/form/projects)

+
+
+
+

HTTP request

+
+
+
GET /files/form/projects HTTP/1.1
+Authorization: admin_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Disposition: form-data; name="attachment"; filename="project_upload_form.xlsx"
+Content-Type: application/octet-stream;charset=UTF-8
+Content-Length: 19
+
+project_upload_form
+
+
+
+
+
+
+
+
+
+

유저 API

+
+
+
+

로그인 유저 기본 정보 조회 (GET /users/me)

+
+
+
+

HTTP request

+
+
+
GET /users/me HTTP/1.1
+Authorization: user_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 323
+
+{
+  "id" : 1,
+  "name" : "이름",
+  "phone" : "010-1234-5678",
+  "email" : "student@g.skku.edu",
+  "userType" : "STUDENT",
+  "division" : null,
+  "position" : null,
+  "studentNumber" : "2000123456",
+  "departmentName" : "학과",
+  "createdAt" : "2024-11-28T19:49:21.977643",
+  "updatedAt" : "2024-11-28T19:49:21.977644"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

사용자 ID

name

String

true

사용자 이름

phone

String

true

사용자 전화번호

email

String

true

사용자 이메일

userType

String

true

사용자 유형

division

String

false

소속

position

String

false

직책

studentNumber

String

false

학번

departmentName

String

false

학과 이름

createdAt

String

true

생성일

updatedAt

String

true

수정일

+
+
+
+
+
+

로그인 유저 기본 정보 수정 (PUT /users/me)

+
+
+
+

HTTP request

+
+
+
PUT /users/me HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: user_access_token
+Content-Length: 195
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "name" : "이름",
+  "phoneNumber" : "010-1234-5678",
+  "email" : "student@g.skku.edu",
+  "division" : null,
+  "position" : null,
+  "studentNumber" : "2000123456",
+  "department" : "학과"
+}
+
+
+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

name

String

true

이름

phoneNumber

String

true

전화번호

email

String

true

이메일

division

String

false

소속

position

String

false

직책

studentNumber

String

false

학번

department

String

false

학과

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 323
+
+{
+  "id" : 1,
+  "name" : "이름",
+  "phone" : "010-1234-5678",
+  "email" : "student@g.skku.edu",
+  "userType" : "STUDENT",
+  "division" : null,
+  "position" : null,
+  "studentNumber" : "2000123456",
+  "departmentName" : "학과",
+  "createdAt" : "2024-11-28T19:49:22.000494",
+  "updatedAt" : "2024-11-28T19:49:22.000495"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

사용자 ID

name

String

true

사용자 이름

phone

String

true

사용자 전화번호

email

String

true

사용자 이메일

userType

String

true

사용자 유형

division

String

false

소속

position

String

false

직책

studentNumber

String

false

학번

departmentName

String

false

학과 이름

createdAt

String

true

생성일

updatedAt

String

true

수정일

+
+
+
+
+
+

유저 탈퇴 (DELETE /users/me)

+
+
+
+

HTTP request

+
+
+
DELETE /users/me HTTP/1.1
+Authorization: user_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+
+

유저 관심 프로젝트 리스트 조회 (GET /users/favorites/projects)

+
+
+
+

HTTP request

+
+
+
GET /users/favorites/projects HTTP/1.1
+Authorization: user_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 1204
+
+[ {
+  "id" : 1,
+  "thumbnailInfo" : {
+    "id" : 1,
+    "uuid" : "썸네일 uuid 1",
+    "name" : "썸네일 파일 이름 1",
+    "mimeType" : "썸네일 mime 타입 1"
+  },
+  "projectName" : "프로젝트 이름 1",
+  "teamName" : "팀 이름 1",
+  "studentNames" : [ "학생 이름 1", "학생 이름 2" ],
+  "professorNames" : [ "교수 이름 1" ],
+  "projectType" : "STARTUP",
+  "projectCategory" : "BIG_DATA_ANALYSIS",
+  "awardStatus" : "FIRST",
+  "year" : 2023,
+  "likeCount" : 100,
+  "like" : false,
+  "bookMark" : false,
+  "url" : "프로젝트 URL",
+  "description" : "프로젝트 설명"
+}, {
+  "id" : 2,
+  "thumbnailInfo" : {
+    "id" : 2,
+    "uuid" : "썸네일 uuid 2",
+    "name" : "썸네일 파일 이름 2",
+    "mimeType" : "썸네일 mime 타입 2"
+  },
+  "projectName" : "프로젝트 이름 2",
+  "teamName" : "팀 이름 2",
+  "studentNames" : [ "학생 이름 3", "학생 이름 4" ],
+  "professorNames" : [ "교수 이름 2" ],
+  "projectType" : "LAB",
+  "projectCategory" : "AI_MACHINE_LEARNING",
+  "awardStatus" : "SECOND",
+  "year" : 2023,
+  "likeCount" : 100,
+  "like" : false,
+  "bookMark" : true,
+  "url" : "프로젝트 URL",
+  "description" : "프로젝트 설명"
+} ]
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

[].id

Number

true

프로젝트 ID

[].thumbnailInfo

Object

true

썸네일 정보

[].thumbnailInfo.id

Number

true

썸네일 ID

[].thumbnailInfo.uuid

String

true

썸네일 UUID

[].thumbnailInfo.name

String

true

썸네일 파일 이름

[].thumbnailInfo.mimeType

String

true

썸네일 MIME 타입

[].projectName

String

true

프로젝트 이름

[].teamName

String

true

팀 이름

[].studentNames[]

Array

true

학생 이름

[].professorNames[]

Array

true

교수 이름

[].projectType

String

true

프로젝트 타입: RESEARCH_AND_BUSINESS_FOUNDATION, LAB, STARTUP, CLUB

[].projectCategory

String

true

프로젝트 카테고리: COMPUTER_VISION, SYSTEM_NETWORK, WEB_APPLICATION, SECURITY_SOFTWARE_ENGINEERING, NATURAL_LANGUAGE_PROCESSING, BIG_DATA_ANALYSIS, AI_MACHINE_LEARNING, INTERACTION_AUGMENTED_REALITY

[].awardStatus

String

true

수상 여부: NONE, FIRST, SECOND, THIRD, FOURTH, FIFTH

[].year

Number

true

프로젝트 년도

[].likeCount

Number

true

좋아요 수

[].like

Boolean

true

좋아요 여부

[].bookMark

Boolean

true

북마크 여부

[].url

String

true

프로젝트 URL

[].description

String

true

프로젝트 설명

+
+
+
+
+
+

유저 관심 대담영상 리스트 조회 (GET /users/favorites/talks)

+
+
+
+

HTTP request

+
+
+
GET /users/favorites/talks HTTP/1.1
+Authorization: user_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 982
+
+[ {
+  "id" : 1,
+  "title" : "제목1",
+  "youtubeId" : "유튜브 고유ID",
+  "year" : 2024,
+  "talkerBelonging" : "대담자 소속1",
+  "talkerName" : "대담자 성명1",
+  "favorite" : true,
+  "quiz" : [ {
+    "question" : "질문1",
+    "answer" : 0,
+    "options" : [ "선지1", "선지2" ]
+  }, {
+    "question" : "질문2",
+    "answer" : 0,
+    "options" : [ "선지1", "선지2" ]
+  } ],
+  "createdAt" : "2024-11-28T19:49:21.961699",
+  "updatedAt" : "2024-11-28T19:49:21.9617"
+}, {
+  "id" : 2,
+  "title" : "제목2",
+  "youtubeId" : "유튜브 고유ID",
+  "year" : 2024,
+  "talkerBelonging" : "대담자 소속2",
+  "talkerName" : "대담자 성명2",
+  "favorite" : true,
+  "quiz" : [ {
+    "question" : "질문1",
+    "answer" : 0,
+    "options" : [ "선지1", "선지2" ]
+  }, {
+    "question" : "질문2",
+    "answer" : 0,
+    "options" : [ "선지1", "선지2" ]
+  } ],
+  "createdAt" : "2024-11-28T19:49:21.961706",
+  "updatedAt" : "2024-11-28T19:49:21.961707"
+} ]
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

[].id

Number

true

대담 영상 ID

[].title

String

true

대담 영상 제목

[].youtubeId

String

true

유튜브 영상의 고유 ID

[].year

Number

true

대담 영상 연도

[].talkerBelonging

String

true

대담자의 소속된 직장/단체

[].talkerName

String

true

대담자의 성명

[].favorite

Boolean

true

관심한 대담영상의 여부

[].quiz

Array

false

퀴즈 데이터, 없는경우 null

[].quiz[].question

String

false

퀴즈 1개의 질문

[].quiz[].answer

Number

false

퀴즈 1개의 정답선지 인덱스

[].quiz[].options

Array

false

퀴즈 1개의 정답선지 리스트

[].createdAt

String

true

대담 영상 생성일

[].updatedAt

String

true

대담 영상 수정일

+
+
+
+
+
+

유저 관심 잡페어인터뷰영상 리스트 조회 (GET /users/favorites/jobInterviews)

+
+
+
+

HTTP request

+
+
+
GET /users/favorites/jobInterviews HTTP/1.1
+Authorization: user_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 690
+
+[ {
+  "id" : 1,
+  "title" : "잡페어 인터뷰의 제목1",
+  "youtubeId" : "유튜브 고유 ID1",
+  "year" : 2023,
+  "talkerBelonging" : "대담자의 소속1",
+  "talkerName" : "대담자의 성명1",
+  "favorite" : false,
+  "category" : "INTERN",
+  "createdAt" : "2024-11-28T19:49:21.953649",
+  "updatedAt" : "2024-11-28T19:49:21.95365"
+}, {
+  "id" : 2,
+  "title" : "잡페어 인터뷰의 제목2",
+  "youtubeId" : "유튜브 고유 ID2",
+  "year" : 2024,
+  "talkerBelonging" : "대담자의 소속2",
+  "talkerName" : "대담자의 성명2",
+  "favorite" : true,
+  "category" : "INTERN",
+  "createdAt" : "2024-11-28T19:49:21.953664",
+  "updatedAt" : "2024-11-28T19:49:21.953665"
+} ]
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

[].id

Number

true

잡페어 인터뷰 ID

[].title

String

true

잡페어 인터뷰 제목

[].youtubeId

String

true

유튜브 영상의 고유 ID

[].year

Number

true

잡페어 인터뷰 연도

[].talkerBelonging

String

true

잡페어 인터뷰 대담자의 소속

[].talkerName

String

true

잡페어 인터뷰 대담자의 성명

[].favorite

Boolean

true

관심에 추가한 잡페어 인터뷰 여부

[].category

String

true

잡페어 인터뷰 카테고리: SENIOR, INTERN

[].createdAt

String

true

잡페어 인터뷰 생성일

[].updatedAt

String

true

잡페어 인터뷰 수정일

+
+
+
+
+
+

유저 문의 리스트 조회 (GET /users/inquiries)

+
+
+
+

HTTP request

+
+
+
GET /users/inquiries HTTP/1.1
+Authorization: user_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 257
+
+[ {
+  "id" : 1,
+  "title" : "Title 1",
+  "projectId" : 1,
+  "createdDate" : "2024-11-28T19:49:21.971752",
+  "hasReply" : true
+}, {
+  "id" : 2,
+  "title" : "Title 2",
+  "projectId" : 2,
+  "createdDate" : "2024-11-28T19:49:21.971758",
+  "hasReply" : false
+} ]
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

[].id

Number

true

문의 ID

[].title

String

true

문의 제목

[].projectId

Number

true

프로젝트 ID

[].createdDate

String

true

문의 생성일

[].hasReply

Boolean

true

답변 여부

+
+
+
+
+
+

유저 과제 제안 리스트 조회 (GET /users/proposals)

+
+
+
+

HTTP request

+
+
+
GET /users/proposals HTTP/1.1
+Authorization: user_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 219
+
+[ {
+  "id" : 1,
+  "title" : "Title 1",
+  "createdDate" : "2024-11-28T19:49:21.983617",
+  "hasReply" : true
+}, {
+  "id" : 2,
+  "title" : "Title 2",
+  "createdDate" : "2024-11-28T19:49:21.983623",
+  "hasReply" : false
+} ]
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

[].id

Number

true

과제 제안 ID

[].title

String

true

프로젝트명

[].createdDate

String

true

과제 제안 생성일

[].hasReply

Boolean

true

답변 여부

+
+
+
+
+
+
+
+

학과 API

+
+
+
+

학과 리스트 조회 (GET /departments)

+
+
+
+

HTTP request

+
+
+
GET /departments HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Host: localhost:8080
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 92
+
+[ {
+  "id" : 1,
+  "name" : "소프트웨어학과"
+}, {
+  "id" : 2,
+  "name" : "학과2"
+} ]
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

[].id

Number

true

학과 ID

[].name

String

true

학과 이름

+
+
+
+
+
+
+
+

가입 신청 관리 API

+
+
+
+

가입 신청 리스트 조회 (GET /applications)

+
+
+
+

HTTP request

+
+
+
GET /applications HTTP/1.1
+Authorization: admin_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Query parameters

+ +++++ + + + + + + + + + + + + + + + + + + + +
ParameterRequiredDescription

page

false

페이지 번호 [default: 0]

size

false

페이지 크기 [default: 10]

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 1178
+
+{
+  "totalPages" : 1,
+  "totalElements" : 3,
+  "first" : true,
+  "last" : true,
+  "size" : 10,
+  "content" : [ {
+    "id" : 1,
+    "name" : "김영한",
+    "division" : "배민",
+    "position" : null,
+    "userType" : "INACTIVE_COMPANY",
+    "createdAt" : "2024-11-28T19:49:21.620883",
+    "updatedAt" : "2024-11-28T19:49:21.620885"
+  }, {
+    "id" : 2,
+    "name" : "김교수",
+    "division" : "솦융대",
+    "position" : "교수",
+    "userType" : "INACTIVE_PROFESSOR",
+    "createdAt" : "2024-11-28T19:49:21.620894",
+    "updatedAt" : "2024-11-28T19:49:21.620895"
+  }, {
+    "id" : 3,
+    "name" : "박교수",
+    "division" : "정통대",
+    "position" : "교수",
+    "userType" : "INACTIVE_PROFESSOR",
+    "createdAt" : "2024-11-28T19:49:21.620897",
+    "updatedAt" : "2024-11-28T19:49:21.620897"
+  } ],
+  "number" : 0,
+  "sort" : {
+    "empty" : true,
+    "sorted" : false,
+    "unsorted" : true
+  },
+  "numberOfElements" : 3,
+  "pageable" : {
+    "pageNumber" : 0,
+    "pageSize" : 10,
+    "sort" : {
+      "empty" : true,
+      "sorted" : false,
+      "unsorted" : true
+    },
+    "offset" : 0,
+    "paged" : true,
+    "unpaged" : false
+  },
+  "empty" : false
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

pageable

Object

true

페이지 정보

pageable.pageNumber

Number

true

현재 페이지 번호

pageable.pageSize

Number

true

페이지 당 요소 수

pageable.sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

pageable.sort.sorted

Boolean

true

정렬된 상태인지 여부

pageable.sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

pageable.offset

Number

true

오프셋

pageable.paged

Boolean

true

페이징된 여부

pageable.unpaged

Boolean

true

페이징되지 않은 여부

totalElements

Number

true

전체 요소 수

totalPages

Number

true

전체 페이지 수

size

Number

true

페이지 당 요소 수

number

Number

true

현재 페이지 번호

numberOfElements

Number

true

현재 페이지 요소 수

first

Boolean

true

첫 페이지 여부

last

Boolean

true

마지막 페이지 여부

sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

sort.sorted

Boolean

true

정렬된 상태인지 여부

empty

Boolean

true

비어있는 페이지 여부

content[].id

Number

true

가입 신청 ID

content[].name

String

true

가입 신청자 이름

content[].division

String

false

소속

content[].position

String

false

직책

content[].userType

String

true

회원 유형 [INACTIVE_PROFESSOR, INACTIVE_COMPANY]

content[].createdAt

String

true

가입 신청 정보 생성일

content[].updatedAt

String

true

가입 신청 정보 수정일

+
+
+
+
+
+

가입 신청자 상세 정보 조회 (GET /applications/{applicationId})

+
+
+
+

HTTP request

+
+
+
GET /applications/1 HTTP/1.1
+Authorization: admin_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /applications/{applicationId}
ParameterDescription

applicationId

가입 신청 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 272
+
+{
+  "id" : 1,
+  "name" : "김영한",
+  "phone" : "010-1111-2222",
+  "email" : "email@gmail.com",
+  "division" : "배민",
+  "position" : "CEO",
+  "userType" : "INACTIVE_COMPANY",
+  "createdAt" : "2024-11-28T19:49:21.643274",
+  "updatedAt" : "2024-11-28T19:49:21.643275"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

가입 신청 ID

name

String

true

가입 신청자 이름

phone

String

true

가입 신청자 전화번호

email

String

true

가입 신청자 이메일

division

String

false

소속

position

String

false

직책

userType

String

true

회원 유형 [INACTIVE_PROFESSOR, INACTIVE_COMPANY]

createdAt

String

true

가입 신청 정보 생성일

updatedAt

String

true

가입 신청 정보 수정일

+
+
+
+
+
+

교수/기업 가입 허가 (PATCH /applications/{applicationId})

+
+
+
+

HTTP request

+
+
+
PATCH /applications/1 HTTP/1.1
+Authorization: admin_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /applications/{applicationId}
ParameterDescription

applicationId

가입 신청 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 262
+
+{
+  "id" : 1,
+  "name" : "김영한",
+  "phone" : "010-1111-2222",
+  "email" : "email@gmail.com",
+  "division" : "배민",
+  "position" : "CEO",
+  "userType" : "COMPANY",
+  "createdAt" : "2024-11-28T19:49:21.637588",
+  "updatedAt" : "2024-11-28T19:49:21.63759"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

가입 신청 ID

name

String

true

가입 신청자 이름

phone

String

true

가입 신청자 전화번호

email

String

true

가입 신청자 이메일

division

String

false

소속

position

String

false

직책

userType

String

true

회원 유형 [PROFESSOR, COMPANY]

createdAt

String

true

가입 신청 정보 생성일

updatedAt

String

true

가입 신청 정보 수정일

+
+
+
+
+
+

교수/기업 가입 거절 (DELETE /applications/{applicationId})

+
+
+
+

HTTP request

+
+
+
DELETE /applications/1 HTTP/1.1
+Authorization: admin_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /applications/{applicationId}
ParameterDescription

applicationId

가입 신청 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+
+
+
+

잡페어 인터뷰 API

+
+
+
+

잡페어 인터뷰 생성 (POST /jobInterviews)

+
+
+
+

HTTP request

+
+
+
POST /jobInterviews HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Content-Length: 213
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "title" : "잡페어 인터뷰의 제목",
+  "youtubeId" : "유튜브 고유 ID",
+  "year" : 2024,
+  "talkerBelonging" : "대담자의 소속",
+  "talkerName" : "대담자의 성명",
+  "category" : "INTERN"
+}
+
+
+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

잡페어 인터뷰 제목

youtubeId

String

true

유튜브 영상의 고유 ID

year

Number

true

잡페어 인터뷰 연도

talkerBelonging

String

true

잡페어 인터뷰 대담자의 소속

talkerName

String

true

잡페어 인터뷰 대담자의 성명

category

String

true

잡페어 인터뷰 카테고리: SENIOR, INTERN

+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 317
+
+{
+  "id" : 1,
+  "title" : "잡페어 인터뷰의 제목",
+  "youtubeId" : "유튜브 고유 ID",
+  "year" : 2024,
+  "talkerBelonging" : "대담자의 소속",
+  "talkerName" : "대담자의 성명",
+  "category" : "INTERN",
+  "createdAt" : "2024-11-28T19:49:22.247595",
+  "updatedAt" : "2024-11-28T19:49:22.247596"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

잡페어 인터뷰 ID

title

String

true

잡페어 인터뷰 제목

youtubeId

String

true

유튜브 영상의 고유 ID

year

Number

true

잡페어 인터뷰 연도

talkerBelonging

String

true

잡페어 인터뷰 대담자의 소속

talkerName

String

true

잡페어 인터뷰 대담자의 성명

category

String

true

잡페어 인터뷰 카테고리: SENIOR, INTERN

createdAt

String

true

잡페어 인터뷰 생성일

updatedAt

String

true

잡페어 인터뷰 수정일

+
+
+
+
+
+

잡페어 인터뷰 리스트 조회 (GET /jobInterviews)

+
+
+
+

HTTP request

+
+
+
GET /jobInterviews HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: optional_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Query parameters

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterRequiredDescription

year

false

찾고자 하는 잡페어 인터뷰 연도

category

false

찾고자 하는 잡페어 인터뷰 카테고리: SENIOR, INTERN

title

false

찾고자 하는 잡페어 인터뷰의 제목

page

false

페이지 번호 [default: 0]

size

false

페이지 크기 [default: 10]

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 1206
+
+{
+  "totalPages" : 1,
+  "totalElements" : 2,
+  "first" : true,
+  "last" : true,
+  "size" : 10,
+  "content" : [ {
+    "id" : 1,
+    "title" : "잡페어 인터뷰의 제목1",
+    "youtubeId" : "유튜브 고유 ID1",
+    "year" : 2023,
+    "talkerBelonging" : "대담자의 소속1",
+    "talkerName" : "대담자의 성명1",
+    "favorite" : false,
+    "category" : "INTERN",
+    "createdAt" : "2024-11-28T19:49:22.228935",
+    "updatedAt" : "2024-11-28T19:49:22.228936"
+  }, {
+    "id" : 2,
+    "title" : "잡페어 인터뷰의 제목2",
+    "youtubeId" : "유튜브 고유 ID2",
+    "year" : 2024,
+    "talkerBelonging" : "대담자의 소속2",
+    "talkerName" : "대담자의 성명2",
+    "favorite" : true,
+    "category" : "INTERN",
+    "createdAt" : "2024-11-28T19:49:22.228941",
+    "updatedAt" : "2024-11-28T19:49:22.228941"
+  } ],
+  "number" : 0,
+  "sort" : {
+    "empty" : true,
+    "sorted" : false,
+    "unsorted" : true
+  },
+  "numberOfElements" : 2,
+  "pageable" : {
+    "pageNumber" : 0,
+    "pageSize" : 10,
+    "sort" : {
+      "empty" : true,
+      "sorted" : false,
+      "unsorted" : true
+    },
+    "offset" : 0,
+    "paged" : true,
+    "unpaged" : false
+  },
+  "empty" : false
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

pageable

Object

true

페이지 정보

pageable.pageNumber

Number

true

현재 페이지 번호

pageable.pageSize

Number

true

페이지 당 요소 수

pageable.sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

pageable.sort.sorted

Boolean

true

정렬된 상태인지 여부

pageable.sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

pageable.offset

Number

true

오프셋

pageable.paged

Boolean

true

페이징된 여부

pageable.unpaged

Boolean

true

페이징되지 않은 여부

totalElements

Number

true

전체 요소 수

totalPages

Number

true

전체 페이지 수

size

Number

true

페이지 당 요소 수

number

Number

true

현재 페이지 번호

numberOfElements

Number

true

현재 페이지 요소 수

first

Boolean

true

첫 페이지 여부

last

Boolean

true

마지막 페이지 여부

sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

sort.sorted

Boolean

true

정렬된 상태인지 여부

empty

Boolean

true

비어있는 페이지 여부

content[].id

Number

true

잡페어 인터뷰 ID

content[].title

String

true

잡페어 인터뷰 제목

content[].youtubeId

String

true

유튜브 영상의 고유 ID

content[].year

Number

true

잡페어 인터뷰 연도

content[].talkerBelonging

String

true

잡페어 인터뷰 대담자의 소속

content[].talkerName

String

true

잡페어 인터뷰 대담자의 성명

content[].favorite

Boolean

true

관심에 추가한 잡페어 인터뷰 여부

content[].category

String

true

잡페어 인터뷰 카테고리: SENIOR, INTERN

content[].createdAt

String

true

잡페어 인터뷰 생성일

content[].updatedAt

String

true

잡페어 인터뷰 수정일

+
+
+
+
+
+

잡페어 인터뷰 단건 조회 (GET /jobInterviews/{jobInterviewId})

+
+
+
+

HTTP request

+
+
+
GET /jobInterviews/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: optional_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /jobInterviews/{jobInterviewId}
ParameterDescription

jobInterviewId

조회할 잡페어 인터뷰의 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 339
+
+{
+  "id" : 1,
+  "title" : "잡페어 인터뷰의 제목",
+  "youtubeId" : "유튜브 고유 ID",
+  "year" : 2024,
+  "talkerBelonging" : "대담자의 소속",
+  "talkerName" : "대담자의 성명",
+  "favorite" : false,
+  "category" : "INTERN",
+  "createdAt" : "2024-11-28T19:49:22.240617",
+  "updatedAt" : "2024-11-28T19:49:22.240618"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

잡페어 인터뷰 ID

title

String

true

잡페어 인터뷰 제목

youtubeId

String

true

유튜브 영상의 고유 ID

year

Number

true

잡페어 인터뷰 연도

talkerBelonging

String

true

잡페어 인터뷰 대담자의 소속

talkerName

String

true

잡페어 인터뷰 대담자의 성명

favorite

Boolean

true

관심에 추가한 잡페어 인터뷰 여부

category

String

true

잡페어 인터뷰 카테고리: SENIOR, INTERN

createdAt

String

true

잡페어 인터뷰 생성일

updatedAt

String

true

잡페어 인터뷰 수정일

+
+
+
+
+
+

잡페어 인터뷰 수정 (PUT /jobInterviews/{jobInterviewId})

+
+
+
+

HTTP request

+
+
+
PUT /jobInterviews/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Content-Length: 217
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "title" : "수정된 제목",
+  "youtubeId" : "수정된 유튜브 ID",
+  "year" : 2024,
+  "talkerBelonging" : "수정된 대담자 소속",
+  "talkerName" : "수정된 대담자 성명",
+  "category" : "INTERN"
+}
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /jobInterviews/{jobInterviewId}
ParameterDescription

jobInterviewId

수정할 잡페어 인터뷰의 ID

+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

잡페어 인터뷰 제목

youtubeId

String

true

유튜브 영상의 고유 ID

year

Number

true

잡페어 인터뷰 연도

talkerBelonging

String

true

잡페어 인터뷰 대담자의 소속

talkerName

String

true

잡페어 인터뷰 대담자의 성명

category

String

true

잡페어 인터뷰 카테고리: SENIOR, INTERN

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 314
+
+{
+  "id" : 1,
+  "title" : "수정된 제목",
+  "youtubeId" : "수정된 유튜브 ID",
+  "year" : 2024,
+  "talkerBelonging" : "수정된 대담자 소속",
+  "talkerName" : "수정된 대담자 성명",
+  "category" : "INTERN",
+  "createdAt" : "2021-01-01T12:00:00",
+  "updatedAt" : "2024-11-28T19:49:22.208502"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

잡페어 인터뷰 ID

title

String

true

잡페어 인터뷰 제목

youtubeId

String

true

유튜브 영상의 고유 ID

year

Number

true

잡페어 인터뷰 연도

talkerBelonging

String

true

잡페어 인터뷰 대담자의 소속

talkerName

String

true

잡페어 인터뷰 대담자의 성명

category

String

true

잡페어 인터뷰 카테고리: SENIOR, INTERN

createdAt

String

true

잡페어 인터뷰 생성일

updatedAt

String

true

잡페어 인터뷰 수정일

+
+
+
+
+
+

잡페어 인터뷰 삭제 (DELETE /jobInterviews/{jobInterviewId})

+
+
+
+

HTTP request

+
+
+
DELETE /jobInterviews/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /jobInterviews/{jobInterviewId}
ParameterDescription

jobInterviewId

삭제할 잡페어 인터뷰의 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+
+

잡페어 인터뷰 관심 등록 (POST /jobInterviews/{jobInterviews}/favorite)

+
+
+
+

HTTP request

+
+
+
POST /jobInterviews/1/favorite HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /jobInterviews/{jobInterviewId}/favorite
ParameterDescription

jobInterviewId

관심 목록에 추가할 잡페어 인터뷰의 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+
+

잡페어 인터뷰 관심 삭제 (DELETE /jobInterviews/{jobInterviews}/favorite)

+
+
+
+

HTTP request

+
+
+
DELETE /jobInterviews/1/favorite HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /jobInterviews/{jobInterviewId}/favorite
ParameterDescription

jobInterviewId

관심 목록에서 삭제할 잡페어 인터뷰의 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+
+
+
+

대담 영상 API

+
+
+
+

대담 영상 생성 (POST /talks)

+
+
+
+

HTTP request

+
+
+
POST /talks HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Content-Length: 361
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "title" : "제목",
+  "youtubeId" : "유튜브 고유ID",
+  "year" : 2024,
+  "talkerBelonging" : "대담자 소속",
+  "talkerName" : "대담자 성명",
+  "quiz" : [ {
+    "question" : "질문1",
+    "answer" : 0,
+    "options" : [ "선지1", "선지2" ]
+  }, {
+    "question" : "질문2",
+    "answer" : 0,
+    "options" : [ "선지1", "선지2" ]
+  } ]
+}
+
+
+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

대담 영상 제목

youtubeId

String

true

유튜브 영상의 고유 ID

year

Number

true

대담 영상 연도

talkerBelonging

String

true

대담자의 소속된 직장/단체

talkerName

String

true

대담자의 성명

quiz

Array

false

퀴즈 데이터, 없는경우 null

quiz[].question

String

false

퀴즈 1개의 질문

quiz[].answer

Number

false

퀴즈 1개의 정답선지 인덱스

quiz[].options

Array

false

퀴즈 1개의 정답선지 리스트

+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 465
+
+{
+  "id" : 1,
+  "title" : "제목",
+  "youtubeId" : "유튜브 고유ID",
+  "year" : 2024,
+  "talkerBelonging" : "대담자 소속",
+  "talkerName" : "대담자 성명",
+  "quiz" : [ {
+    "question" : "질문1",
+    "answer" : 0,
+    "options" : [ "선지1", "선지2" ]
+  }, {
+    "question" : "질문2",
+    "answer" : 0,
+    "options" : [ "선지1", "선지2" ]
+  } ],
+  "createdAt" : "2024-11-28T19:49:22.817542",
+  "updatedAt" : "2024-11-28T19:49:22.817543"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

대담 영상 ID

title

String

true

대담 영상 제목

youtubeId

String

true

유튜브 영상의 고유 ID

year

Number

true

대담 영상 연도

talkerBelonging

String

true

대담자의 소속된 직장/단체

talkerName

String

true

대담자의 성명

quiz

Array

false

퀴즈 데이터, 없는경우 null

quiz[].question

String

false

퀴즈 1개의 질문

quiz[].answer

Number

false

퀴즈 1개의 정답선지 인덱스

quiz[].options

Array

false

퀴즈 1개의 정답선지 리스트

createdAt

String

true

대담 영상 생성일

updatedAt

String

true

대담 영상 수정일

+
+
+
+
+
+

대담 영상 리스트 조회 (GET /talks)

+
+
+
+

HTTP request

+
+
+
GET /talks HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: optional_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Query parameters

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterRequiredDescription

year

false

찾고자 하는 대담 영상의 연도

title

false

찾고자 하는 대담 영상의 제목 일부

page

false

페이지 번호 [default: 0]

size

false

페이지 크기 [default: 10]

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 999
+
+{
+  "totalPages" : 1,
+  "totalElements" : 1,
+  "first" : true,
+  "last" : true,
+  "size" : 10,
+  "content" : [ {
+    "id" : 1,
+    "title" : "제목",
+    "youtubeId" : "유튜브 고유ID",
+    "year" : 2024,
+    "talkerBelonging" : "대담자 소속",
+    "talkerName" : "대담자 성명",
+    "favorite" : true,
+    "quiz" : [ {
+      "question" : "질문1",
+      "answer" : 0,
+      "options" : [ "선지1", "선지2" ]
+    }, {
+      "question" : "질문2",
+      "answer" : 0,
+      "options" : [ "선지1", "선지2" ]
+    } ],
+    "createdAt" : "2024-11-28T19:49:22.824681",
+    "updatedAt" : "2024-11-28T19:49:22.824682"
+  } ],
+  "number" : 0,
+  "sort" : {
+    "empty" : true,
+    "sorted" : false,
+    "unsorted" : true
+  },
+  "numberOfElements" : 1,
+  "pageable" : {
+    "pageNumber" : 0,
+    "pageSize" : 10,
+    "sort" : {
+      "empty" : true,
+      "sorted" : false,
+      "unsorted" : true
+    },
+    "offset" : 0,
+    "paged" : true,
+    "unpaged" : false
+  },
+  "empty" : false
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

pageable

Object

true

페이지 정보

pageable.pageNumber

Number

true

현재 페이지 번호

pageable.pageSize

Number

true

페이지 당 요소 수

pageable.sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

pageable.sort.sorted

Boolean

true

정렬된 상태인지 여부

pageable.sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

pageable.offset

Number

true

오프셋

pageable.paged

Boolean

true

페이징된 여부

pageable.unpaged

Boolean

true

페이징되지 않은 여부

totalElements

Number

true

전체 요소 수

totalPages

Number

true

전체 페이지 수

size

Number

true

페이지 당 요소 수

number

Number

true

현재 페이지 번호

numberOfElements

Number

true

현재 페이지 요소 수

first

Boolean

true

첫 페이지 여부

last

Boolean

true

마지막 페이지 여부

sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

sort.sorted

Boolean

true

정렬된 상태인지 여부

empty

Boolean

true

비어있는 페이지 여부

content[].id

Number

true

대담 영상 ID

content[].title

String

true

대담 영상 제목

content[].youtubeId

String

true

유튜브 영상의 고유 ID

content[].year

Number

true

대담 영상 연도

content[].talkerBelonging

String

true

대담자의 소속된 직장/단체

content[].talkerName

String

true

대담자의 성명

content[].favorite

Boolean

true

관심한 대담영상의 여부

content[].quiz

Array

false

퀴즈 데이터, 없는경우 null

content[].quiz[].question

String

false

퀴즈 1개의 질문

content[].quiz[].answer

Number

false

퀴즈 1개의 정답선지 인덱스

content[].quiz[].options

Array

false

퀴즈 1개의 정답선지 리스트

content[].createdAt

String

true

대담 영상 생성일

content[].updatedAt

String

true

대담 영상 수정일

+
+
+
+
+
+

대담 영상 단건 조회 (GET /talks/{talkId})

+
+
+
+

HTTP request

+
+
+
GET /talks/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: optional_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /talks/{talkId}
ParameterDescription

talkId

조회할 대담 영상의 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 486
+
+{
+  "id" : 1,
+  "title" : "제목",
+  "youtubeId" : "유튜브 고유ID",
+  "year" : 2024,
+  "talkerBelonging" : "대담자 소속",
+  "talkerName" : "대담자 성명",
+  "favorite" : true,
+  "quiz" : [ {
+    "question" : "질문1",
+    "answer" : 0,
+    "options" : [ "선지1", "선지2" ]
+  }, {
+    "question" : "질문2",
+    "answer" : 0,
+    "options" : [ "선지1", "선지2" ]
+  } ],
+  "createdAt" : "2024-11-28T19:49:22.809931",
+  "updatedAt" : "2024-11-28T19:49:22.809933"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

대담 영상 ID

title

String

true

대담 영상 제목

youtubeId

String

true

유튜브 영상의 고유 ID

year

Number

true

대담 영상 연도

talkerBelonging

String

true

대담자의 소속된 직장/단체

talkerName

String

true

대담자의 성명

favorite

Boolean

true

관심한 대담영상의 여부

quiz

Array

false

퀴즈 데이터, 없는경우 null

quiz[].question

String

false

퀴즈 1개의 질문

quiz[].answer

Number

false

퀴즈 1개의 정답선지 인덱스

quiz[].options

Array

false

퀴즈 1개의 정답선지 리스트

createdAt

String

true

대담 영상 생성일

updatedAt

String

true

대담 영상 수정일

+
+
+
+
+
+

대담 영상 수정 (PUT /talks/{talkId})

+
+
+
+

HTTP request

+
+
+
PUT /talks/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Content-Length: 461
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "title" : "수정한 제목",
+  "youtubeId" : "수정한 유튜브 고유ID",
+  "year" : 2024,
+  "talkerBelonging" : "수정한 대담자 소속",
+  "talkerName" : "수정한 대담자 성명",
+  "quiz" : [ {
+    "question" : "수정한 질문1",
+    "answer" : 0,
+    "options" : [ "수정한 선지1", "수정한 선지2" ]
+  }, {
+    "question" : "수정한 질문2",
+    "answer" : 0,
+    "options" : [ "수정한 선지1", "수정한 선지2" ]
+  } ]
+}
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /talks/{talkId}
ParameterDescription

talkId

수정할 대담 영상의 ID

+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

대담 영상 제목

youtubeId

String

true

유튜브 영상의 고유 ID

year

Number

true

대담 영상 연도

talkerBelonging

String

true

대담자의 소속된 직장/단체

talkerName

String

true

대담자의 성명

quiz

Array

false

퀴즈 데이터, 없는경우 null

quiz[].question

String

false

퀴즈 1개의 질문

quiz[].answer

Number

false

퀴즈 1개의 정답선지 인덱스

quiz[].options

Array

false

퀴즈 1개의 정답선지 리스트

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 565
+
+{
+  "id" : 1,
+  "title" : "수정한 제목",
+  "youtubeId" : "수정한 유튜브 고유ID",
+  "year" : 2024,
+  "talkerBelonging" : "수정한 대담자 소속",
+  "talkerName" : "수정한 대담자 성명",
+  "quiz" : [ {
+    "question" : "수정한 질문1",
+    "answer" : 0,
+    "options" : [ "수정한 선지1", "수정한 선지2" ]
+  }, {
+    "question" : "수정한 질문2",
+    "answer" : 0,
+    "options" : [ "수정한 선지1", "수정한 선지2" ]
+  } ],
+  "createdAt" : "2024-11-28T19:49:22.777287",
+  "updatedAt" : "2024-11-28T19:49:22.777288"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

대담 영상 ID

title

String

true

대담 영상 제목

youtubeId

String

true

유튜브 영상의 고유 ID

year

Number

true

대담 영상 연도

talkerBelonging

String

true

대담자의 소속된 직장/단체

talkerName

String

true

대담자의 성명

quiz

Array

false

퀴즈 데이터, 없는경우 null

quiz[].question

String

false

퀴즈 1개의 질문

quiz[].answer

Number

false

퀴즈 1개의 정답선지 인덱스

quiz[].options

Array

false

퀴즈 1개의 정답선지 리스트

createdAt

String

true

대담 영상 생성일

updatedAt

String

true

대담 영상 수정일

+
+
+
+
+
+

대담 영상 삭제 (DELETE /talks/{talkId})

+
+
+
+

HTTP request

+
+
+
DELETE /talks/1 HTTP/1.1
+Authorization: admin_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /talks/{talkId}
ParameterDescription

talkId

삭제할 대담 영상의 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+
+

대담 영상의 퀴즈 조회 (GET /talks/{talkId}/quiz)

+
+
+
+

HTTP request

+
+
+
GET /talks/1/quiz HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Host: localhost:8080
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /talks/{talkId}/quiz
ParameterDescription

talkId

퀴즈를 가져올 대담 영상의 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 205
+
+{
+  "quiz" : [ {
+    "question" : "질문1",
+    "answer" : 0,
+    "options" : [ "선지1", "선지2" ]
+  }, {
+    "question" : "질문2",
+    "answer" : 0,
+    "options" : [ "선지1", "선지2" ]
+  } ]
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

quiz

Array

false

퀴즈 데이터, 없는경우 null

quiz[].question

String

false

퀴즈 1개의 질문

quiz[].answer

Number

false

퀴즈 1개의 정답선지 인덱스

quiz[].options

Array

false

퀴즈 1개의 정답선지 리스트

+
+
+
+
+
+

대담 영상의 퀴즈 결과 제출 (POST /talks/{talkId}/quiz)

+
+
+
+

HTTP request

+
+
+
POST /talks/1/quiz HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: access_token
+Content-Length: 47
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "result" : {
+    "0" : 0,
+    "1" : 1
+  }
+}
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /talks/{talkId}/quiz
ParameterDescription

talkId

퀴즈를 제출할 대담 영상의 ID

+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

result

Object

true

퀴즈를 푼 결과

result.*

Number

true

퀴즈 각 문제별 정답 인덱스, key는 문제 번호

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 40
+
+{
+  "success" : true,
+  "tryCount" : 1
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

success

Boolean

true

퀴즈 성공 여부

tryCount

Number

true

퀴즈 시도 횟수

+
+
+
+
+
+

대담 영상의 관심 등록 (POST /talks/{talkId}/favorite)

+
+
+
+

HTTP request

+
+
+
POST /talks/1/favorite HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /talks/{talkId}/favorite
ParameterDescription

talkId

관심 목록에 추가할 대담 영상의 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+
+

대담 영상의 관심 삭제 (DELETE /talks/{talkId}/favorite)

+
+
+
+

HTTP request

+
+
+
DELETE /talks/1/favorite HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /talks/{talkId}/favorite
ParameterDescription

talkId

관심 목록에서 삭제할 대담 영상의 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+
+

유저의 퀴즈 제출 기록 조회 (GET /talks/{talkId/quiz/submit)

+
+
+
+

HTTP request

+
+
+
GET /talks/1/quiz/submit HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /talks/{talkId}/quiz/submit
ParameterDescription

talkId

퀴즈가 연결된 대담 영상의 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 41
+
+{
+  "success" : false,
+  "tryCount" : 2
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

tryCount

Number

true

시도한 횟수

success

Boolean

true

퀴즈 성공 여부

+
+
+
+
+
+
+
+

퀴즈 결과 API

+
+
+
+

퀴즈 결과 조회 (GET /quizzes/result)

+
+
+
+

HTTP request

+
+
+
GET /quizzes/result HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Query parameters

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterRequiredDescription

year

false

찾고자 하는 퀴즈 푼 문제의 연도

page

false

페이지 번호 [default: 0]

size

false

페이지 크기 [default: 10]

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 739
+
+{
+  "totalPages" : 1,
+  "totalElements" : 2,
+  "first" : true,
+  "last" : true,
+  "size" : 10,
+  "content" : [ {
+    "userId" : 1,
+    "name" : "name",
+    "phone" : "010-1111-1111",
+    "email" : "scg@scg.skku.ac.kr",
+    "successCount" : 3
+  }, {
+    "userId" : 2,
+    "name" : "name2",
+    "phone" : "010-0000-1234",
+    "email" : "iam@2tle.io",
+    "successCount" : 1
+  } ],
+  "number" : 0,
+  "sort" : {
+    "empty" : true,
+    "sorted" : false,
+    "unsorted" : true
+  },
+  "numberOfElements" : 2,
+  "pageable" : {
+    "pageNumber" : 0,
+    "pageSize" : 10,
+    "sort" : {
+      "empty" : true,
+      "sorted" : false,
+      "unsorted" : true
+    },
+    "offset" : 0,
+    "paged" : true,
+    "unpaged" : false
+  },
+  "empty" : false
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

pageable

Object

true

페이지 정보

pageable.pageNumber

Number

true

현재 페이지 번호

pageable.pageSize

Number

true

페이지 당 요소 수

pageable.sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

pageable.sort.sorted

Boolean

true

정렬된 상태인지 여부

pageable.sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

pageable.offset

Number

true

오프셋

pageable.paged

Boolean

true

페이징된 여부

pageable.unpaged

Boolean

true

페이징되지 않은 여부

totalElements

Number

true

전체 요소 수

totalPages

Number

true

전체 페이지 수

size

Number

true

페이지 당 요소 수

number

Number

true

현재 페이지 번호

numberOfElements

Number

true

현재 페이지 요소 수

first

Boolean

true

첫 페이지 여부

last

Boolean

true

마지막 페이지 여부

sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

sort.sorted

Boolean

true

정렬된 상태인지 여부

empty

Boolean

true

비어있는 페이지 여부

content[].userId

Number

true

유저의 아이디

content[].name

String

true

유저의 이름

content[].phone

String

true

유저의 연락처

content[].email

String

true

유저의 이메일

content[].successCount

Number

true

유저가 퀴즈를 성공한 횟수의 합

+
+
+
+
+
+

퀴즈 결과를 엑셀로 받기 (GET /quizzes/result/excel)

+
+
+
+

HTTP request

+
+
+
GET /quizzes/result/excel HTTP/1.1
+Content-Type: application/octet-stream;charset=UTF-8
+Authorization: admin_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Query parameters

+ +++++ + + + + + + + + + + + + + + +
ParameterRequiredDescription

year

false

찾고자 하는 퀴즈 푼 문제의 연도, 기본값 올해

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/octet-stream;charset=UTF-8
+Content-Disposition: attachment; filename=excel.xlsx
+Content-Length: 2821
+
+PK-[Content_Types].xml�S�n1��U��&����X8�ql�J?�M��y)1��^�RT�S��xfl%��ڻj���q+G� ���k����~U!\؈�t2�o��[CiDO��*�GEƄ��6f�e�T����ht�t��j4�d��-,UO��A�����S�U0G��^Pft[N�m*7L�˚Uv�0Z�:��q�������E�mk5����[$�M�23Y��A�7�,��<c�(���x֢cƳ�E�GӖ�L��;Yz�h>(�c�b��/�s�Ɲ��`�\s|J6�r��y���௶�j�PK�q,-�PK-_rels/.rels���j�0�_���8�`�Q��2�m��4[ILb��ږ���.[K
+�($}��v?�I�Q.���uӂ�h���x>=��@��p�H"�~�}�	�n����*"�H�׺؁�����8�Z�^'�#��7m{��O�3���G�u�ܓ�'��y|a�����D�	��l_EYȾ����vql3�ML�eh���*���\3�Y0���oJ׏�	:��^��}PK��z��IPK-docProps/app.xmlM��
+�0D�~EȽ��ADҔ���A? ��6�lB�J?ߜ���0���ͯ�)�@��׍H6���V>��$;�SC
+;̢(�ra�g�l�&�e��L!y�%��49��`_���4G���F��J��Wg
�GS�b����
+~�PK�|wؑ�PK-docProps/core.xmlm�]K�0��J�}{�n�m�(Aq�D�.$Ƕ�|�D;��i�Ի$�sN�j{�yGzkj��4Ҫ޴5}8�
%!
+��`
��X�m*鸴��:���@��.]M��C-B���뵈��[pB���,�3��Q�$��b�'���ҽ�a(	8�F���������,�1�5�c>�f.m�����~^>���u���Nj.=���$�.5�<�.�;ڔE����́|}���_���l}s�
+���'ny��O��'PKqBj�PK-xl/sharedStrings.xml=�A� ツ��.z0Ɣ�`������,�����q2��o�ԇ���N�E��x5�z>�W���(R�K���^4{�����ŀ�5��y�V����y�m�XV�\�.�j����
8�PKp��&x�PK-
xl/styles.xml���n� ��>bop2TQ��P)U�RWb�6*�����ӤS�Nw�s���3ߍ֐���t��(l��������ҝx�!N=@$ɀ��}��3c���ʰr`:i��2��w,�
+�d
�T��R#�voc �;c�iE��Û��E<|��4Iɣ����F#��n���B�z�F���y�j3y��yҥ�jt>���2��Lژ�!6��2F�OY��4@M�!���G��������1�t��y��p��"	n����u�����a�ΦDi�9�&#��%I��9��}���cK��T��$?������`J������7���o��f��M|PK�1X@C�PK-xl/workbook.xmlM���0��>E�wi1ƨ����z/�HmI�_j��qf��)ʧٌ��w�LC��ָ��[u��T�b�a��؊;���8�9��G�)��5�|�:�2<8MuK=b�#�	q�V�u
����K��H\)�\�&�t͌��%���?��B��T�PK	���PK-xl/_rels/workbook.xml.rels��ͪ1�_�d�tt!"V7�n������LZ�(����r����p����6�JY���U
����s����;[�E8D&a��h@-
��$� Xt�im���F�*&�41���̭M��ؒ]����WL�f�}��9anIH���Qs1���KtO��ll���O���X߬�	�{�ŋ����œ�?o'�>PK�(2��PK-�q,-�[Content_Types].xmlPK-��z��Iv_rels/.relsPK-�|wؑ��docProps/app.xmlPK-qBj�qdocProps/core.xmlPK-p��&x��xl/sharedStrings.xmlPK-�1X@C�
xl/styles.xmlPK-	���xl/workbook.xmlPK-�(2���xl/_rels/workbook.xml.relsPK��
+
+
+
+
+
+
+
+
+
+

공지 사항 API

+
+
+

공지 사항 생성 (POST /notices)

+
+
+
+

HTTP request

+
+
+
POST /notices HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Content-Length: 121
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "title" : "공지 사항 제목",
+  "content" : "공지 사항 내용",
+  "fixed" : true,
+  "fileIds" : [ 1, 2, 3 ]
+}
+
+
+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

공지 사항 제목

content

String

true

공지 사항 내용

fixed

Boolean

true

공지 사항 고정 여부

fileIds

Array

false

첨부 파일 ID 목록

+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 960
+
+{
+  "id" : 1,
+  "title" : "공지 사항 제목",
+  "content" : "공지 사항 내용",
+  "hitCount" : 0,
+  "fixed" : true,
+  "createdAt" : "2024-11-28T19:49:20.765811",
+  "updatedAt" : "2024-11-28T19:49:20.765812",
+  "files" : [ {
+    "id" : 1,
+    "uuid" : "014eb8a0-d4a6-11ee-adac-117d766aca1d",
+    "name" : "예시 첨부 파일 1.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:20.765803",
+    "updatedAt" : "2024-11-28T19:49:20.765806"
+  }, {
+    "id" : 2,
+    "uuid" : "11a480c0-13fa-11ef-9047-570191b390ea",
+    "name" : "예시 첨부 파일 2.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:20.765807",
+    "updatedAt" : "2024-11-28T19:49:20.765808"
+  }, {
+    "id" : 3,
+    "uuid" : "1883fc70-cfb4-11ee-a387-e754bd392d45",
+    "name" : "예시 첨부 파일 3.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:20.765809",
+    "updatedAt" : "2024-11-28T19:49:20.76581"
+  } ]
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

공지 사항 ID

title

String

true

공지 사항 제목

content

String

true

공지 사항 내용

hitCount

Number

true

공지 사항 조회수

fixed

Boolean

true

공지 사항 고정 여부

createdAt

String

true

공지 사항 생성일

updatedAt

String

true

공지 사항 수정일

files

Array

true

첨부 파일 목록

files[].id

Number

true

파일 ID

files[].uuid

String

true

파일 고유 식별자

files[].name

String

true

파일 이름

files[].mimeType

String

true

파일 MIME 타입

files[].createdAt

String

true

파일 생성 시간

files[].updatedAt

String

true

파일 수정 시간

+
+
+
+
+
+

공지 사항 리스트 조회 (GET /notices)

+
+
+
+

Query parameters

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterRequiredDescription

terms

false

검색어 (optional)

scope

false

검색 범위 (title, content, both) [default: both, searchTerm=null 이면 null로 초기화]

page

false

페이지 번호 [default: 0]

size

false

페이지 크기 [default: 10]

+
+
+

HTTP request

+
+
+
GET /notices?terms=notice&scope=title&page=0&size=10 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Host: localhost:8080
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 838
+
+{
+  "totalPages" : 1,
+  "totalElements" : 2,
+  "first" : true,
+  "last" : true,
+  "size" : 10,
+  "content" : [ {
+    "id" : 1,
+    "title" : "notice 1",
+    "hitCount" : 10,
+    "fixed" : true,
+    "createdAt" : "2024-11-28T19:49:20.7253",
+    "updatedAt" : "2024-11-28T19:49:20.725302"
+  }, {
+    "id" : 2,
+    "title" : "notice 2",
+    "hitCount" : 10,
+    "fixed" : false,
+    "createdAt" : "2024-11-28T19:49:20.725317",
+    "updatedAt" : "2024-11-28T19:49:20.725318"
+  } ],
+  "number" : 0,
+  "sort" : {
+    "empty" : true,
+    "sorted" : false,
+    "unsorted" : true
+  },
+  "numberOfElements" : 2,
+  "pageable" : {
+    "pageNumber" : 0,
+    "pageSize" : 10,
+    "sort" : {
+      "empty" : true,
+      "sorted" : false,
+      "unsorted" : true
+    },
+    "offset" : 0,
+    "paged" : true,
+    "unpaged" : false
+  },
+  "empty" : false
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

content[].id

Number

true

공지 사항 ID

content[].title

String

true

공지 사항 제목

content[].hitCount

Number

true

공지 사항 조회수

content[].fixed

Boolean

true

공지 사항 고정 여부

content[].createdAt

String

true

공지 사항 생성일

content[].updatedAt

String

true

공지 사항 수정일

pageable

Object

true

페이지 정보

pageable.pageNumber

Number

true

현재 페이지 번호

pageable.pageSize

Number

true

페이지 당 요소 수

pageable.sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

pageable.sort.sorted

Boolean

true

정렬된 상태인지 여부

pageable.sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

pageable.offset

Number

true

오프셋

pageable.paged

Boolean

true

페이징된 여부

pageable.unpaged

Boolean

true

페이징되지 않은 여부

totalElements

Number

true

전체 요소 수

totalPages

Number

true

전체 페이지 수

size

Number

true

페이지 당 요소 수

number

Number

true

현재 페이지 번호

numberOfElements

Number

true

현재 페이지 요소 수

first

Boolean

true

첫 페이지 여부

last

Boolean

true

마지막 페이지 여부

sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

sort.sorted

Boolean

true

정렬된 상태인지 여부

empty

Boolean

true

비어있는 페이지 여부

+
+
+
+
+
+

공지 사항 조회 (GET /notices/{noticeId})

+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /notices/{noticeId}
ParameterDescription

noticeId

조회할 공지 사항 ID

+
+
+

HTTP request

+
+
+
GET /notices/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Host: localhost:8080
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 949
+
+{
+  "id" : 1,
+  "title" : "공지 사항 제목",
+  "content" : "content",
+  "hitCount" : 10,
+  "fixed" : true,
+  "createdAt" : "2024-11-28T19:49:20.757948",
+  "updatedAt" : "2024-11-28T19:49:20.757949",
+  "files" : [ {
+    "id" : 1,
+    "uuid" : "014eb8a0-d4a6-11ee-adac-117d766aca1d",
+    "name" : "예시 첨부 파일 1.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:20.757939",
+    "updatedAt" : "2024-11-28T19:49:20.757942"
+  }, {
+    "id" : 2,
+    "uuid" : "11a480c0-13fa-11ef-9047-570191b390ea",
+    "name" : "예시 첨부 파일 2.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:20.757943",
+    "updatedAt" : "2024-11-28T19:49:20.757944"
+  }, {
+    "id" : 3,
+    "uuid" : "1883fc70-cfb4-11ee-a387-e754bd392d45",
+    "name" : "예시 첨부 파일 3.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:20.757945",
+    "updatedAt" : "2024-11-28T19:49:20.757946"
+  } ]
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

공지 사항 ID

title

String

true

공지 사항 제목

content

String

true

공지 사항 내용

hitCount

Number

true

공지 사항 조회수

fixed

Boolean

true

공지 사항 고정 여부

createdAt

String

true

공지 사항 생성일

updatedAt

String

true

공지 사항 수정일

files

Array

true

첨부 파일 목록

files[].id

Number

true

파일 ID

files[].uuid

String

true

파일 고유 식별자

files[].name

String

true

파일 이름

files[].mimeType

String

true

파일 MIME 타입

files[].createdAt

String

true

파일 생성 시간

files[].updatedAt

String

true

파일 수정 시간

+
+
+
+
+
+

공지 사항 수정 (PUT /notices/{noticeId})

+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /notices/{noticeId}
ParameterDescription

noticeId

수정할 공지 사항 ID

+
+
+

HTTP request

+
+
+
PUT /notices/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Content-Length: 142
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "title" : "수정된 공지 사항 제목",
+  "content" : "수정된 공지 사항 내용",
+  "fixed" : false,
+  "fileIds" : [ 1, 2, 3 ]
+}
+
+
+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

공지 사항 제목

content

String

true

공지 사항 내용

fixed

Boolean

true

공지 사항 고정 여부

fileIds

Array

false

첨부 파일 ID 목록

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 955
+
+{
+  "id" : 1,
+  "title" : "수정된 공지 사항 제목",
+  "content" : "수정된 공지 사항 내용",
+  "hitCount" : 10,
+  "fixed" : false,
+  "createdAt" : "2024-01-01T12:00:00",
+  "updatedAt" : "2024-11-28T19:49:20.739212",
+  "files" : [ {
+    "id" : 1,
+    "uuid" : "014eb8a0-d4a6-11ee-adac-117d766aca1d",
+    "name" : "예시 첨부 파일 1.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-01-01T12:00:00",
+    "updatedAt" : "2024-11-28T19:49:20.739182"
+  }, {
+    "id" : 2,
+    "uuid" : "11a480c0-13fa-11ef-9047-570191b390ea",
+    "name" : "예시 첨부 파일 2.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-01-01T12:00:00",
+    "updatedAt" : "2024-11-28T19:49:20.739187"
+  }, {
+    "id" : 3,
+    "uuid" : "1883fc70-cfb4-11ee-a387-e754bd392d45",
+    "name" : "예시 첨부 파일 3.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-01-01T12:00:00",
+    "updatedAt" : "2024-11-28T19:49:20.739189"
+  } ]
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

공지 사항 ID

title

String

true

공지 사항 제목

content

String

true

공지 사항 내용

hitCount

Number

true

공지 사항 조회수

fixed

Boolean

true

공지 사항 고정 여부

createdAt

String

true

공지 사항 생성일

updatedAt

String

true

공지 사항 수정일

files

Array

true

첨부 파일 목록

files[].id

Number

true

파일 ID

files[].uuid

String

true

파일 고유 식별자

files[].name

String

true

파일 이름

files[].mimeType

String

true

파일 MIME 타입

files[].createdAt

String

true

파일 생성 시간

files[].updatedAt

String

true

파일 수정 시간

+
+
+
+
+
+

공지 사항 삭제 (DELETE /notices/{noticeId})

+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /notices/{noticeId}
ParameterDescription

noticeId

삭제할 공지 사항 ID

+
+
+

HTTP request

+
+
+
DELETE /notices/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+
+
+
+

이벤트 공지 사항 API

+
+
+

이벤트 공지 사항 생성 (POST /eventNotices)

+
+
+
+

HTTP request

+
+
+
POST /eventNotices HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Content-Length: 141
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "title" : "이벤트 공지 사항 제목",
+  "content" : "이벤트 공지 사항 내용",
+  "fixed" : true,
+  "fileIds" : [ 1, 2, 3 ]
+}
+
+
+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

이벤트 공지 사항 제목

content

String

true

이벤트 공지 사항 내용

fixed

Boolean

true

이벤트 공지 사항 고정 여부

fileIds

Array

false

첨부 파일 ID 목록

+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 980
+
+{
+  "id" : 1,
+  "title" : "이벤트 공지 사항 제목",
+  "content" : "이벤트 공지 사항 내용",
+  "hitCount" : 0,
+  "fixed" : true,
+  "createdAt" : "2024-11-28T19:49:19.013422",
+  "updatedAt" : "2024-11-28T19:49:19.013423",
+  "files" : [ {
+    "id" : 1,
+    "uuid" : "014eb8a0-d4a6-11ee-adac-117d766aca1d",
+    "name" : "예시 첨부 파일 1.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:19.013406",
+    "updatedAt" : "2024-11-28T19:49:19.013412"
+  }, {
+    "id" : 2,
+    "uuid" : "11a480c0-13fa-11ef-9047-570191b390ea",
+    "name" : "예시 첨부 파일 2.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:19.013414",
+    "updatedAt" : "2024-11-28T19:49:19.013416"
+  }, {
+    "id" : 3,
+    "uuid" : "1883fc70-cfb4-11ee-a387-e754bd392d45",
+    "name" : "예시 첨부 파일 3.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:19.013418",
+    "updatedAt" : "2024-11-28T19:49:19.01342"
+  } ]
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

이벤트 공지 사항 ID

title

String

true

이벤트 공지 사항 제목

content

String

true

이벤트 공지 사항 내용

hitCount

Number

true

이벤트 공지 사항 조회수

fixed

Boolean

true

이벤트 공지 사항 고정 여부

createdAt

String

true

이벤트 공지 사항 생성일

updatedAt

String

true

이벤트 공지 사항 수정일

files

Array

true

첨부 파일 목록

files[].id

Number

true

파일 ID

files[].uuid

String

true

파일 고유 식별자

files[].name

String

true

파일 이름

files[].mimeType

String

true

파일 MIME 타입

files[].createdAt

String

true

파일 생성 시간

files[].updatedAt

String

true

파일 수정 시간

+
+
+
+
+
+

이벤트 공지 사항 리스트 조회 (GET /eventNotices)

+
+
+
+

Query parameters

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterRequiredDescription

terms

false

검색어 (optional)

scope

false

검색 범위 (title, content, both) [default: both, searchTerm=null 이면 null로 초기화]

page

false

페이지 번호 [default: 0]

size

false

페이지 크기 [default: 10]

+
+
+

HTTP request

+
+
+
GET /eventNotices?terms=notice&scope=title&page=0&size=10 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Host: localhost:8080
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 852
+
+{
+  "totalPages" : 1,
+  "totalElements" : 2,
+  "first" : true,
+  "last" : true,
+  "size" : 10,
+  "content" : [ {
+    "id" : 1,
+    "title" : "event notice 1",
+    "hitCount" : 10,
+    "fixed" : true,
+    "createdAt" : "2024-11-28T19:49:19.026853",
+    "updatedAt" : "2024-11-28T19:49:19.026858"
+  }, {
+    "id" : 2,
+    "title" : "event notice 2",
+    "hitCount" : 10,
+    "fixed" : false,
+    "createdAt" : "2024-11-28T19:49:19.026866",
+    "updatedAt" : "2024-11-28T19:49:19.026867"
+  } ],
+  "number" : 0,
+  "sort" : {
+    "empty" : true,
+    "sorted" : false,
+    "unsorted" : true
+  },
+  "numberOfElements" : 2,
+  "pageable" : {
+    "pageNumber" : 0,
+    "pageSize" : 10,
+    "sort" : {
+      "empty" : true,
+      "sorted" : false,
+      "unsorted" : true
+    },
+    "offset" : 0,
+    "paged" : true,
+    "unpaged" : false
+  },
+  "empty" : false
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

content[].id

Number

true

이벤트 공지 사항 ID

content[].title

String

true

이벤트 공지 사항 제목

content[].hitCount

Number

true

이벤트 공지 사항 조회수

content[].fixed

Boolean

true

이벤트 공지 사항 고정 여부

content[].createdAt

String

true

이벤트 공지 사항 생성일

content[].updatedAt

String

true

이벤트 공지 사항 수정일

pageable

Object

true

페이지 정보

pageable.pageNumber

Number

true

현재 페이지 번호

pageable.pageSize

Number

true

페이지 당 요소 수

pageable.sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

pageable.sort.sorted

Boolean

true

정렬된 상태인지 여부

pageable.sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

pageable.offset

Number

true

오프셋

pageable.paged

Boolean

true

페이징된 여부

pageable.unpaged

Boolean

true

페이징되지 않은 여부

totalElements

Number

true

전체 요소 수

totalPages

Number

true

전체 페이지 수

size

Number

true

페이지 당 요소 수

number

Number

true

현재 페이지 번호

numberOfElements

Number

true

현재 페이지 요소 수

first

Boolean

true

첫 페이지 여부

last

Boolean

true

마지막 페이지 여부

sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

sort.sorted

Boolean

true

정렬된 상태인지 여부

empty

Boolean

true

비어있는 페이지 여부

+
+
+
+
+
+

이벤트 공지 사항 조회 (GET /eventNotices/{eventNoticeId})

+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /eventNotices/{eventNoticeId}
ParameterDescription

eventNoticeId

조회할 이벤트 공지 사항 ID

+
+
+

HTTP request

+
+
+
GET /eventNotices/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Host: localhost:8080
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 958
+
+{
+  "id" : 1,
+  "title" : "이벤트 공지 사항 제목",
+  "content" : "content",
+  "hitCount" : 10,
+  "fixed" : true,
+  "createdAt" : "2024-11-28T19:49:18.99268",
+  "updatedAt" : "2024-11-28T19:49:18.992682",
+  "files" : [ {
+    "id" : 1,
+    "uuid" : "014eb8a0-d4a6-11ee-adac-117d766aca1d",
+    "name" : "예시 첨부 파일 1.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:18.992663",
+    "updatedAt" : "2024-11-28T19:49:18.992668"
+  }, {
+    "id" : 2,
+    "uuid" : "11a480c0-13fa-11ef-9047-570191b390ea",
+    "name" : "예시 첨부 파일 2.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:18.992671",
+    "updatedAt" : "2024-11-28T19:49:18.992673"
+  }, {
+    "id" : 3,
+    "uuid" : "1883fc70-cfb4-11ee-a387-e754bd392d45",
+    "name" : "예시 첨부 파일 3.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:18.992674",
+    "updatedAt" : "2024-11-28T19:49:18.992677"
+  } ]
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

이벤트 공지 사항 ID

title

String

true

이벤트 공지 사항 제목

content

String

true

이벤트 공지 사항 내용

hitCount

Number

true

이벤트 공지 사항 조회수

fixed

Boolean

true

이벤트 공지 사항 고정 여부

createdAt

String

true

이벤트 공지 사항 생성일

updatedAt

String

true

이벤트 공지 사항 수정일

files

Array

true

첨부 파일 목록

files[].id

Number

true

파일 ID

files[].uuid

String

true

파일 고유 식별자

files[].name

String

true

파일 이름

files[].mimeType

String

true

파일 MIME 타입

files[].createdAt

String

true

파일 생성 시간

files[].updatedAt

String

true

파일 수정 시간

+
+
+
+
+
+

이벤트 공지 사항 수정 (PUT /eventNotices/{eventNoticeId})

+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /eventNotices/{eventNoticeId}
ParameterDescription

eventNoticeId

수정할 이벤트 공지 사항 ID

+
+
+

HTTP request

+
+
+
PUT /eventNotices/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Content-Length: 162
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "title" : "수정된 이벤트 공지 사항 제목",
+  "content" : "수정된 이벤트 공지 사항 내용",
+  "fixed" : false,
+  "fileIds" : [ 1, 2, 3 ]
+}
+
+
+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

이벤트 공지 사항 제목

content

String

true

이벤트 공지 사항 내용

fixed

Boolean

true

이벤트 공지 사항 고정 여부

fileIds

Array

false

첨부 파일 ID 목록

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 975
+
+{
+  "id" : 1,
+  "title" : "수정된 이벤트 공지 사항 제목",
+  "content" : "수정된 이벤트 공지 사항 내용",
+  "hitCount" : 10,
+  "fixed" : false,
+  "createdAt" : "2024-01-01T12:00:00",
+  "updatedAt" : "2024-11-28T19:49:18.969893",
+  "files" : [ {
+    "id" : 1,
+    "uuid" : "014eb8a0-d4a6-11ee-adac-117d766aca1d",
+    "name" : "예시 첨부 파일 1.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-01-01T12:00:00",
+    "updatedAt" : "2024-11-28T19:49:18.969851"
+  }, {
+    "id" : 2,
+    "uuid" : "11a480c0-13fa-11ef-9047-570191b390ea",
+    "name" : "예시 첨부 파일 2.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-01-01T12:00:00",
+    "updatedAt" : "2024-11-28T19:49:18.969863"
+  }, {
+    "id" : 3,
+    "uuid" : "1883fc70-cfb4-11ee-a387-e754bd392d45",
+    "name" : "예시 첨부 파일 3.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-01-01T12:00:00",
+    "updatedAt" : "2024-11-28T19:49:18.969868"
+  } ]
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

이벤트 공지 사항 ID

title

String

true

이벤트 공지 사항 제목

content

String

true

이벤트 공지 사항 내용

hitCount

Number

true

이벤트 공지 사항 조회수

fixed

Boolean

true

이벤트 공지 사항 고정 여부

createdAt

String

true

이벤트 공지 사항 생성일

updatedAt

String

true

이벤트 공지 사항 수정일

files

Array

true

첨부 파일 목록

files[].id

Number

true

파일 ID

files[].uuid

String

true

파일 고유 식별자

files[].name

String

true

파일 이름

files[].mimeType

String

true

파일 MIME 타입

files[].createdAt

String

true

파일 생성 시간

files[].updatedAt

String

true

파일 수정 시간

+
+
+
+
+
+

이벤트 공지 사항 삭제 (DELETE /eventNotices/{eventNoticeId})

+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /eventNotices/{eventNoticeId}
ParameterDescription

eventNoticeId

삭제할 이벤트 공지 사항 ID

+
+
+

HTTP request

+
+
+
DELETE /eventNotices/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+
+
+
+

이벤트 기간 API

+
+
+
+

이벤트 기간 생성 (POST /eventPeriods)

+
+
+
+

HTTP request

+
+
+
POST /eventPeriods HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Content-Length: 84
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "start" : "2024-11-28T19:49:19.262199",
+  "end" : "2024-12-08T19:49:19.262203"
+}
+
+
+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

start

String

true

이벤트 시작 일시

end

String

true

이벤트 종료 일시

+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 204
+
+{
+  "id" : 1,
+  "year" : 2024,
+  "start" : "2024-11-28T19:49:19.262206",
+  "end" : "2024-12-08T19:49:19.262208",
+  "createdAt" : "2024-11-28T19:49:19.26221",
+  "updatedAt" : "2024-11-28T19:49:19.262211"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

이벤트 기간 ID

year

Number

true

이벤트 연도

start

String

true

이벤트 시작 일시

end

String

true

이벤트 종료 일시

createdAt

String

true

생성일

updatedAt

String

true

변경일

+
+
+
+
+
+

올해의 이벤트 기간 조회 (GET /eventPeriod)

+
+
+
+

HTTP request

+
+
+
GET /eventPeriod HTTP/1.1
+Authorization: admin_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 205
+
+{
+  "id" : 1,
+  "year" : 2024,
+  "start" : "2024-11-28T19:49:19.254084",
+  "end" : "2024-12-08T19:49:19.254086",
+  "createdAt" : "2024-11-28T19:49:19.254089",
+  "updatedAt" : "2024-11-28T19:49:19.254091"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

이벤트 기간 ID

year

Number

true

이벤트 연도

start

String

true

이벤트 시작 일시

end

String

true

이벤트 종료 일시

createdAt

String

true

생성일

updatedAt

String

true

변경일

+
+
+
+
+
+

전체 이벤트 기간 리스트 조회 (GET /eventPeriods)

+
+
+
+

HTTP request

+
+
+
GET /eventPeriods HTTP/1.1
+Authorization: admin_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 416
+
+[ {
+  "id" : 1,
+  "year" : 2024,
+  "start" : "2024-11-28T19:49:19.278318",
+  "end" : "2024-12-08T19:49:19.278322",
+  "createdAt" : "2024-11-28T19:49:19.278325",
+  "updatedAt" : "2024-11-28T19:49:19.278331"
+}, {
+  "id" : 2,
+  "year" : 2025,
+  "start" : "2024-11-28T19:49:19.278333",
+  "end" : "2024-12-08T19:49:19.278334",
+  "createdAt" : "2024-11-28T19:49:19.278336",
+  "updatedAt" : "2024-11-28T19:49:19.278338"
+} ]
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

[].id

Number

true

이벤트 기간 ID

[].year

Number

true

이벤트 연도

[].start

String

true

이벤트 시작 일시

[].end

String

true

이벤트 종료 일시

[].createdAt

String

true

생성일

[].updatedAt

String

true

변경일

+
+
+
+
+
+

올해의 이벤트 기간 업데이트 (PUT /eventPeriod)

+
+
+
+

HTTP request

+
+
+
PUT /eventPeriod HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Content-Length: 83
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "start" : "2024-11-28T19:49:19.23885",
+  "end" : "2024-12-08T19:49:19.238855"
+}
+
+
+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

start

String

true

이벤트 시작 일시

end

String

true

이벤트 종료 일시

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 205
+
+{
+  "id" : 1,
+  "year" : 2024,
+  "start" : "2024-11-28T19:49:19.238886",
+  "end" : "2024-12-08T19:49:19.238887",
+  "createdAt" : "2024-11-28T19:49:19.238889",
+  "updatedAt" : "2024-11-28T19:49:19.238891"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

이벤트 기간 ID

year

Number

true

이벤트 연도

start

String

true

이벤트 시작 일시

end

String

true

이벤트 종료 일시

createdAt

String

true

생성일

updatedAt

String

true

변경일

+
+
+
+
+
+
+
+

갤러리 API

+
+
+
+

갤러리 게시글 생성 (POST /galleries)

+
+
+
+

HTTP request

+
+
+
POST /galleries HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Content-Length: 96
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "title" : "새내기 배움터",
+  "year" : 2024,
+  "month" : 4,
+  "fileIds" : [ 1, 2, 3 ]
+}
+
+
+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

제목

year

Number

true

연도

month

Number

true

fileIds

Array

true

파일 ID 리스트

+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 890
+
+{
+  "id" : 1,
+  "title" : "새내기 배움터",
+  "year" : 2024,
+  "month" : 4,
+  "hitCount" : 1,
+  "createdAt" : "2024-11-28T19:49:20.00415",
+  "updatedAt" : "2024-11-28T19:49:20.004152",
+  "files" : [ {
+    "id" : 1,
+    "uuid" : "014eb8a0-d4a6-11ee-adac-117d766aca1d",
+    "name" : "사진1.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:20.004133",
+    "updatedAt" : "2024-11-28T19:49:20.004137"
+  }, {
+    "id" : 2,
+    "uuid" : "11a480c0-13fa-11ef-9047-570191b390ea",
+    "name" : "사진2.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:20.004139",
+    "updatedAt" : "2024-11-28T19:49:20.004141"
+  }, {
+    "id" : 3,
+    "uuid" : "1883fc70-cfb4-11ee-a387-e754bd392d45",
+    "name" : "사진3.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:20.004142",
+    "updatedAt" : "2024-11-28T19:49:20.004144"
+  } ]
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

갤러리 ID

title

String

true

제목

year

Number

true

연도

month

Number

true

hitCount

Number

true

조회수

createdAt

String

true

생성일

updatedAt

String

true

수정일

files

Array

true

파일 목록

files[].id

Number

true

파일 ID

files[].uuid

String

true

파일 UUID

files[].name

String

true

파일 이름

files[].mimeType

String

true

파일 MIME 타입

files[].createdAt

String

true

파일 생성일

files[].updatedAt

String

true

파일 수정일

+
+
+
+
+
+

갤러리 게시글 목록 조회 (GET /galleries)

+
+
+
+

HTTP request

+
+
+
GET /galleries?year=2024&month=4 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Host: localhost:8080
+
+
+
+
+

Query parameters

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterRequiredDescription

year

false

연도

month

false

page

false

페이지 번호 [default: 0]

size

false

페이지 크기 [default: 10]

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 1234
+
+{
+  "totalPages" : 1,
+  "totalElements" : 1,
+  "first" : true,
+  "last" : true,
+  "size" : 1,
+  "content" : [ {
+    "id" : 1,
+    "title" : "새내기 배움터",
+    "year" : 2024,
+    "month" : 4,
+    "hitCount" : 0,
+    "createdAt" : "2024-11-28T19:49:19.979402",
+    "updatedAt" : "2024-11-28T19:49:19.979404",
+    "files" : [ {
+      "id" : 1,
+      "uuid" : "014eb8a0-d4a6-11ee-adac-117d766aca1d",
+      "name" : "사진1.jpg",
+      "mimeType" : "image/jpeg",
+      "createdAt" : "2024-11-28T19:49:19.979386",
+      "updatedAt" : "2024-11-28T19:49:19.97939"
+    }, {
+      "id" : 2,
+      "uuid" : "11a480c0-13fa-11ef-9047-570191b390ea",
+      "name" : "사진2.jpg",
+      "mimeType" : "image/jpeg",
+      "createdAt" : "2024-11-28T19:49:19.979392",
+      "updatedAt" : "2024-11-28T19:49:19.979394"
+    }, {
+      "id" : 3,
+      "uuid" : "1883fc70-cfb4-11ee-a387-e754bd392d45",
+      "name" : "사진3.jpg",
+      "mimeType" : "image/jpeg",
+      "createdAt" : "2024-11-28T19:49:19.979396",
+      "updatedAt" : "2024-11-28T19:49:19.979397"
+    } ]
+  } ],
+  "number" : 0,
+  "sort" : {
+    "empty" : true,
+    "sorted" : false,
+    "unsorted" : true
+  },
+  "numberOfElements" : 1,
+  "pageable" : "INSTANCE",
+  "empty" : false
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

totalElements

Number

true

전체 데이터 수

totalPages

Number

true

전체 페이지 수

size

Number

true

페이지당 데이터 수

content

Array

true

갤러리 목록

content[].id

Number

true

갤러리 ID

content[].title

String

true

갤러리 제목

content[].year

Number

true

갤러리 연도

content[].month

Number

true

갤러리 월

content[].hitCount

Number

true

갤러리 조회수

content[].createdAt

String

true

갤러리 생성일

content[].updatedAt

String

true

갤러리 수정일

content[].files

Array

true

파일 목록

content[].files[].id

Number

true

파일 ID

content[].files[].uuid

String

true

파일 UUID

content[].files[].name

String

true

파일 이름

content[].files[].mimeType

String

true

파일 MIME 타입

content[].files[].createdAt

String

true

파일 생성일

content[].files[].updatedAt

String

true

파일 수정일

number

Number

true

현재 페이지 번호

sort.empty

Boolean

true

정렬 정보

sort.sorted

Boolean

true

정렬 정보

sort.unsorted

Boolean

true

정렬 정보

first

Boolean

true

첫 페이지 여부

last

Boolean

true

마지막 페이지 여부

numberOfElements

Number

true

현재 페이지의 데이터 수

empty

Boolean

true

빈 페이지 여부

+
+
+
+
+
+

갤러리 게시글 조회 (GET /galleries/{galleryId})

+
+
+
+

HTTP request

+
+
+
GET /galleries/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Host: localhost:8080
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /galleries/{galleryId}
ParameterDescription

galleryId

조회할 갤러리 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 890
+
+{
+  "id" : 1,
+  "title" : "새내기 배움터",
+  "year" : 2024,
+  "month" : 4,
+  "hitCount" : 1,
+  "createdAt" : "2024-11-28T19:49:19.995362",
+  "updatedAt" : "2024-11-28T19:49:19.995363",
+  "files" : [ {
+    "id" : 1,
+    "uuid" : "014eb8a0-d4a6-11ee-adac-117d766aca1d",
+    "name" : "사진1.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:19.995349",
+    "updatedAt" : "2024-11-28T19:49:19.995353"
+  }, {
+    "id" : 2,
+    "uuid" : "11a480c0-13fa-11ef-9047-570191b390ea",
+    "name" : "사진2.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:19.995355",
+    "updatedAt" : "2024-11-28T19:49:19.995357"
+  }, {
+    "id" : 3,
+    "uuid" : "1883fc70-cfb4-11ee-a387-e754bd392d45",
+    "name" : "사진3.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:19.995358",
+    "updatedAt" : "2024-11-28T19:49:19.99536"
+  } ]
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

갤러리 ID

title

String

true

제목

year

Number

true

연도

month

Number

true

hitCount

Number

true

조회수

createdAt

String

true

생성일

updatedAt

String

true

수정일

files

Array

true

파일 목록

files[].id

Number

true

파일 ID

files[].uuid

String

true

파일 UUID

files[].name

String

true

파일 이름

files[].mimeType

String

true

파일 MIME 타입

files[].createdAt

String

true

파일 생성일

files[].updatedAt

String

true

파일 수정일

+
+
+
+
+
+

갤러리 게시글 삭제 (DELETE /galleries/{galleryId})

+
+
+
+

HTTP request

+
+
+
DELETE /galleries/1 HTTP/1.1
+Authorization: admin_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /galleries/{galleryId}
ParameterDescription

galleryId

삭제할 갤러리 ID

+
+
+
+
+
+

갤러리 게시글 수정 (PUT /galleries/{galleryId})

+
+
+
+

HTTP request

+
+
+
PUT /galleries/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Content-Length: 93
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "title" : "수정된 제목",
+  "year" : 2024,
+  "month" : 5,
+  "fileIds" : [ 1, 2, 3 ]
+}
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /galleries/{galleryId}
ParameterDescription

galleryId

수정할 갤러리 ID

+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

제목

year

Number

true

연도

month

Number

true

fileIds

Array

true

파일 ID 리스트

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 885
+
+{
+  "id" : 1,
+  "title" : "수정된 제목",
+  "year" : 2024,
+  "month" : 5,
+  "hitCount" : 1,
+  "createdAt" : "2024-11-28T19:49:19.953599",
+  "updatedAt" : "2024-11-28T19:49:19.9536",
+  "files" : [ {
+    "id" : 1,
+    "uuid" : "014eb8a0-d4a6-11ee-adac-117d766aca1d",
+    "name" : "사진1.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:19.95354",
+    "updatedAt" : "2024-11-28T19:49:19.953546"
+  }, {
+    "id" : 2,
+    "uuid" : "11a480c0-13fa-11ef-9047-570191b390ea",
+    "name" : "사진2.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:19.953552",
+    "updatedAt" : "2024-11-28T19:49:19.953553"
+  }, {
+    "id" : 3,
+    "uuid" : "1883fc70-cfb4-11ee-a387-e754bd392d45",
+    "name" : "사진3.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:19.953556",
+    "updatedAt" : "2024-11-28T19:49:19.953557"
+  } ]
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

갤러리 ID

title

String

true

제목

year

Number

true

연도

month

Number

true

hitCount

Number

true

조회수

createdAt

String

true

생성일

updatedAt

String

true

수정일

files

Array

true

파일 목록

files[].id

Number

true

파일 ID

files[].uuid

String

true

파일 UUID

files[].name

String

true

파일 이름

files[].mimeType

String

true

파일 MIME 타입

files[].createdAt

String

true

파일 생성일

files[].updatedAt

String

true

파일 수정일

+
+
+
+
+
+
+
+

프로젝트 API

+
+
+
+

프로젝트 조회 (GET /projects)

+
+
+
+

HTTP request

+
+
+
GET /projects HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: optional_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 1759
+
+{
+  "totalPages" : 1,
+  "totalElements" : 2,
+  "first" : true,
+  "last" : true,
+  "size" : 10,
+  "content" : [ {
+    "id" : 1,
+    "thumbnailInfo" : {
+      "id" : 1,
+      "uuid" : "썸네일 uuid 1",
+      "name" : "썸네일 파일 이름 1",
+      "mimeType" : "썸네일 mime 타입 1"
+    },
+    "projectName" : "프로젝트 이름 1",
+    "teamName" : "팀 이름 1",
+    "studentNames" : [ "학생 이름 1", "학생 이름 2" ],
+    "professorNames" : [ "교수 이름 1" ],
+    "projectType" : "STARTUP",
+    "projectCategory" : "BIG_DATA_ANALYSIS",
+    "awardStatus" : "FIRST",
+    "year" : 2023,
+    "likeCount" : 100,
+    "like" : false,
+    "bookMark" : false,
+    "url" : "프로젝트 URL",
+    "description" : "프로젝트 설명"
+  }, {
+    "id" : 2,
+    "thumbnailInfo" : {
+      "id" : 2,
+      "uuid" : "썸네일 uuid 2",
+      "name" : "썸네일 파일 이름 2",
+      "mimeType" : "썸네일 mime 타입 2"
+    },
+    "projectName" : "프로젝트 이름 2",
+    "teamName" : "팀 이름 2",
+    "studentNames" : [ "학생 이름 3", "학생 이름 4" ],
+    "professorNames" : [ "교수 이름 2" ],
+    "projectType" : "LAB",
+    "projectCategory" : "AI_MACHINE_LEARNING",
+    "awardStatus" : "SECOND",
+    "year" : 2023,
+    "likeCount" : 100,
+    "like" : false,
+    "bookMark" : true,
+    "url" : "프로젝트 URL",
+    "description" : "프로젝트 설명"
+  } ],
+  "number" : 0,
+  "sort" : {
+    "empty" : true,
+    "sorted" : false,
+    "unsorted" : true
+  },
+  "numberOfElements" : 2,
+  "pageable" : {
+    "pageNumber" : 0,
+    "pageSize" : 10,
+    "sort" : {
+      "empty" : true,
+      "sorted" : false,
+      "unsorted" : true
+    },
+    "offset" : 0,
+    "paged" : true,
+    "unpaged" : false
+  },
+  "empty" : false
+}
+
+
+
+
+

Query parameters

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterRequiredDescription

title

false

프로젝트 이름

year

false

프로젝트 년도

category

false

프로젝트 카테고리

type

false

프로젝트 타입

page

false

페이지 번호 [default: 0]

size

false

페이지 크기 [default: 10]

sort

false

정렬 기준

+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

content[].id

Number

true

프로젝트 ID

content[].thumbnailInfo

Object

true

썸네일 정보

content[].thumbnailInfo.id

Number

true

썸네일 ID

content[].thumbnailInfo.uuid

String

true

썸네일 UUID

content[].thumbnailInfo.name

String

true

썸네일 파일 이름

content[].thumbnailInfo.mimeType

String

true

썸네일 MIME 타입

content[].projectName

String

true

프로젝트 이름

content[].teamName

String

true

팀 이름

content[].studentNames[]

Array

true

학생 이름

content[].professorNames[]

Array

true

교수 이름

content[].projectType

String

true

프로젝트 타입: RESEARCH_AND_BUSINESS_FOUNDATION, LAB, STARTUP, CLUB

content[].projectCategory

String

true

프로젝트 카테고리: COMPUTER_VISION, SYSTEM_NETWORK, WEB_APPLICATION, SECURITY_SOFTWARE_ENGINEERING, NATURAL_LANGUAGE_PROCESSING, BIG_DATA_ANALYSIS, AI_MACHINE_LEARNING, INTERACTION_AUGMENTED_REALITY

content[].awardStatus

String

true

수상 여부: NONE, FIRST, SECOND, THIRD, FOURTH, FIFTH

content[].year

Number

true

프로젝트 년도

content[].likeCount

Number

true

좋아요 수

content[].like

Boolean

true

좋아요 여부

content[].bookMark

Boolean

true

북마크 여부

content[].url

String

true

프로젝트 URL

content[].description

String

true

프로젝트 설명

pageable

Object

true

페이지 정보

pageable.pageNumber

Number

true

현재 페이지 번호

pageable.pageSize

Number

true

페이지 당 요소 수

pageable.sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

pageable.sort.sorted

Boolean

true

정렬된 상태인지 여부

pageable.sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

pageable.offset

Number

true

오프셋

pageable.paged

Boolean

true

페이징된 여부

pageable.unpaged

Boolean

true

페이징되지 않은 여부

totalElements

Number

true

전체 요소 수

totalPages

Number

true

전체 페이지 수

size

Number

true

페이지 당 요소 수

number

Number

true

현재 페이지 번호

numberOfElements

Number

true

현재 페이지 요소 수

first

Boolean

true

첫 페이지 여부

last

Boolean

true

마지막 페이지 여부

sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

sort.sorted

Boolean

true

정렬된 상태인지 여부

empty

Boolean

true

비어있는 페이지 여부

+
+
+
+
+
+

프로젝트 생성 (POST /projects)

+
+
+
+

HTTP request

+
+
+
POST /projects HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Content-Length: 535
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "thumbnailId" : 1,
+  "posterId" : 2,
+  "projectName" : "프로젝트 이름",
+  "projectType" : "STARTUP",
+  "projectCategory" : "BIG_DATA_ANALYSIS",
+  "teamName" : "팀 이름",
+  "youtubeId" : "유튜브 ID",
+  "year" : 2021,
+  "awardStatus" : "NONE",
+  "members" : [ {
+    "name" : "학생 이름 1",
+    "role" : "STUDENT"
+  }, {
+    "name" : "학생 이름 2",
+    "role" : "STUDENT"
+  }, {
+    "name" : "교수 이름 1",
+    "role" : "PROFESSOR"
+  } ],
+  "url" : "프로젝트 URL",
+  "description" : "프로젝트 설명"
+}
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 1481
+
+{
+  "id" : 1,
+  "thumbnailInfo" : {
+    "id" : 2,
+    "uuid" : "썸네일 uuid",
+    "name" : "썸네일 파일 이름",
+    "mimeType" : "썸네일 mime 타입"
+  },
+  "posterInfo" : {
+    "id" : 2,
+    "uuid" : "포스터 uuid",
+    "name" : "포트서 파일 이름",
+    "mimeType" : "포스터 mime 타입"
+  },
+  "projectName" : "프로젝트 이름",
+  "projectType" : "STARTUP",
+  "projectCategory" : "BIG_DATA_ANALYSIS",
+  "teamName" : "팀 이름",
+  "youtubeId" : "유튜브 ID",
+  "year" : 2024,
+  "awardStatus" : "FIRST",
+  "studentNames" : [ "학생 이름 1", "학생 이름 2" ],
+  "professorNames" : [ "교수 이름 1" ],
+  "likeCount" : 0,
+  "like" : false,
+  "bookMark" : false,
+  "comments" : [ {
+    "id" : 1,
+    "projectId" : 1,
+    "userName" : "유저 이름",
+    "isAnonymous" : true,
+    "content" : "댓글 내용",
+    "createdAt" : "2024-11-28T19:49:21.053603",
+    "updatedAt" : "2024-11-28T19:49:21.053605"
+  }, {
+    "id" : 2,
+    "projectId" : 1,
+    "userName" : "유저 이름",
+    "isAnonymous" : false,
+    "content" : "댓글 내용",
+    "createdAt" : "2024-11-28T19:49:21.053606",
+    "updatedAt" : "2024-11-28T19:49:21.053607"
+  }, {
+    "id" : 3,
+    "projectId" : 1,
+    "userName" : "유저 이름",
+    "isAnonymous" : false,
+    "content" : "댓글 내용",
+    "createdAt" : "2024-11-28T19:49:21.053608",
+    "updatedAt" : "2024-11-28T19:49:21.053608"
+  } ],
+  "url" : "프로젝트 URL",
+  "description" : "프로젝트 설명"
+}
+
+
+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

thumbnailId

Number

true

썸네일 ID

posterId

Number

true

포스터 ID

projectName

String

true

프로젝트 이름

projectType

String

true

프로젝트 타입: RESEARCH_AND_BUSINESS_FOUNDATION, LAB, STARTUP, CLUB

projectCategory

String

true

프로젝트 카테고리: COMPUTER_VISION, SYSTEM_NETWORK, WEB_APPLICATION, SECURITY_SOFTWARE_ENGINEERING, NATURAL_LANGUAGE_PROCESSING, BIG_DATA_ANALYSIS, AI_MACHINE_LEARNING, INTERACTION_AUGMENTED_REALITY

teamName

String

true

팀 이름

youtubeId

String

true

프로젝트 youtubeId

year

Number

true

프로젝트 년도

awardStatus

String

true

수상 여부: NONE, FIRST, SECOND, THIRD, FOURTH, FIFTH

members

Array

true

멤버

members[].name

String

true

멤버 이름

members[].role

String

true

멤버 역할: STUDENT, PROFESSOR

url

String

true

프로젝트 URL

description

String

true

프로젝트 설명

+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

프로젝트 ID

thumbnailInfo

Object

true

썸네일 정보

thumbnailInfo.id

Number

true

썸네일 ID

thumbnailInfo.uuid

String

true

썸네일 UUID

thumbnailInfo.name

String

true

썸네일 파일 이름

thumbnailInfo.mimeType

String

true

썸네일 MIME 타입

posterInfo

Object

true

포스터 정보

posterInfo.id

Number

true

포스터 ID

posterInfo.uuid

String

true

포스터 UUID

posterInfo.name

String

true

포스터 파일 이름

posterInfo.mimeType

String

true

포스터 MIME 타입

projectName

String

true

프로젝트 이름

projectType

String

true

프로젝트 타입: RESEARCH_AND_BUSINESS_FOUNDATION, LAB, STARTUP, CLUB

projectCategory

String

true

프로젝트 카테고리: COMPUTER_VISION, SYSTEM_NETWORK, WEB_APPLICATION, SECURITY_SOFTWARE_ENGINEERING, NATURAL_LANGUAGE_PROCESSING, BIG_DATA_ANALYSIS, AI_MACHINE_LEARNING, INTERACTION_AUGMENTED_REALITY

teamName

String

true

팀 이름

youtubeId

String

true

프로젝트 youtubeId

year

Number

true

프로젝트 년도

awardStatus

String

true

수상 여부: NONE, FIRST, SECOND, THIRD, FOURTH, FIFTH

studentNames

Array

true

학생 이름

professorNames

Array

true

교수 이름

likeCount

Number

true

좋아요 수

like

Boolean

true

좋아요 여부

bookMark

Boolean

true

북마크 여부

url

String

true

프로젝트 URL

description

String

true

프로젝트 설명

comments

Array

true

댓글

comments[].id

Number

true

댓글 ID

comments[].projectId

Number

true

유저 ID

comments[].userName

String

true

유저 이름

comments[].isAnonymous

Boolean

true

익명 여부

comments[].content

String

true

댓글 내용

comments[].createdAt

String

true

생성 시간

comments[].updatedAt

String

true

수정 시간

+
+
+
+
+
+

프로젝트 엑셀 일괄등록 (POST /projects/excel)

+
+
+
+

HTTP request

+
+
+
POST /projects/excel HTTP/1.1
+Content-Type: multipart/form-data;charset=UTF-8; boundary=6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Authorization: admin_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Disposition: form-data; name=excel; filename=project_upload_form.xlsx
+Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
+
+[BINARY DATA]
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Disposition: form-data; name=thumbnails; filename=thumbnails.json
+Content-Type: application/json
+
+[{"id":1,"uuid":"033f326e-3ed6-4279-ba3b-c14b0c7eb0e5","name":"썸네일1.png","mimeType":"image/png","createdAt":"2024-11-28T19:49:21.218223","updatedAt":"2024-11-28T19:49:21.218224"},{"id":1,"uuid":"2d47118a-bd5d-4054-ac0f-f4b4d7cde727","name":"썸네일2.png","mimeType":"image/png","createdAt":"2024-11-28T19:49:21.218241","updatedAt":"2024-11-28T19:49:21.218242"}]
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Disposition: form-data; name=posters; filename=thumbnails.json
+Content-Type: application/json
+
+[{"id":1,"uuid":"7249fab0-93da-440c-b1de-b9c60c79b597","name":"포스터1.png","mimeType":"image/png","createdAt":"2024-11-28T19:49:21.218257","updatedAt":"2024-11-28T19:49:21.218258"},{"id":1,"uuid":"84807b2b-e8c5-43e3-a92a-b14626c9030b","name":"포스터2.png","mimeType":"image/png","createdAt":"2024-11-28T19:49:21.218262","updatedAt":"2024-11-28T19:49:21.218263"}]
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm--
+
+
+
+
+

Request parts

+ ++++ + + + + + + + + + + + + + + + + + + + + +
PartDescription

excel

업로드할 Excel 파일

thumbnails

썸네일 등록 응답 JSON 파일

posters

포스터 등록 응답 JSON 파일

+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 24
+
+{
+  "successCount" : 2
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

successCount

Number

true

생성에 성공한 프로젝트 개수

+
+
+
+
+
+

프로젝트 조회 (GET /projects/{projectId})

+
+
+
+

HTTP request

+
+
+
GET /projects/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: optional_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 1481
+
+{
+  "id" : 1,
+  "thumbnailInfo" : {
+    "id" : 2,
+    "uuid" : "썸네일 uuid",
+    "name" : "썸네일 파일 이름",
+    "mimeType" : "썸네일 mime 타입"
+  },
+  "posterInfo" : {
+    "id" : 2,
+    "uuid" : "포스터 uuid",
+    "name" : "포트서 파일 이름",
+    "mimeType" : "포스터 mime 타입"
+  },
+  "projectName" : "프로젝트 이름",
+  "projectType" : "STARTUP",
+  "projectCategory" : "BIG_DATA_ANALYSIS",
+  "teamName" : "팀 이름",
+  "youtubeId" : "유튜브 ID",
+  "year" : 2024,
+  "awardStatus" : "FIRST",
+  "studentNames" : [ "학생 이름 1", "학생 이름 2" ],
+  "professorNames" : [ "교수 이름 1" ],
+  "likeCount" : 0,
+  "like" : false,
+  "bookMark" : false,
+  "comments" : [ {
+    "id" : 1,
+    "projectId" : 1,
+    "userName" : "유저 이름",
+    "isAnonymous" : true,
+    "content" : "댓글 내용",
+    "createdAt" : "2024-11-28T19:49:21.029498",
+    "updatedAt" : "2024-11-28T19:49:21.029501"
+  }, {
+    "id" : 2,
+    "projectId" : 1,
+    "userName" : "유저 이름",
+    "isAnonymous" : false,
+    "content" : "댓글 내용",
+    "createdAt" : "2024-11-28T19:49:21.029504",
+    "updatedAt" : "2024-11-28T19:49:21.029505"
+  }, {
+    "id" : 3,
+    "projectId" : 1,
+    "userName" : "유저 이름",
+    "isAnonymous" : false,
+    "content" : "댓글 내용",
+    "createdAt" : "2024-11-28T19:49:21.029506",
+    "updatedAt" : "2024-11-28T19:49:21.029506"
+  } ],
+  "url" : "프로젝트 URL",
+  "description" : "프로젝트 설명"
+}
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /projects/{projectId}
ParameterDescription

projectId

프로젝트 ID

+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

프로젝트 ID

thumbnailInfo

Object

true

썸네일 정보

thumbnailInfo.id

Number

true

썸네일 ID

thumbnailInfo.uuid

String

true

썸네일 UUID

thumbnailInfo.name

String

true

썸네일 파일 이름

thumbnailInfo.mimeType

String

true

썸네일 MIME 타입

posterInfo

Object

true

포스터 정보

posterInfo.id

Number

true

포스터 ID

posterInfo.uuid

String

true

포스터 UUID

posterInfo.name

String

true

포스터 파일 이름

posterInfo.mimeType

String

true

포스터 MIME 타입

projectName

String

true

프로젝트 이름

projectType

String

true

프로젝트 타입: RESEARCH_AND_BUSINESS_FOUNDATION, LAB, STARTUP, CLUB

projectCategory

String

true

프로젝트 카테고리: COMPUTER_VISION, SYSTEM_NETWORK, WEB_APPLICATION, SECURITY_SOFTWARE_ENGINEERING, NATURAL_LANGUAGE_PROCESSING, BIG_DATA_ANALYSIS, AI_MACHINE_LEARNING, INTERACTION_AUGMENTED_REALITY

teamName

String

true

팀 이름

youtubeId

String

true

프로젝트 youtubeId

year

Number

true

프로젝트 년도

awardStatus

String

true

수상 여부: NONE, FIRST, SECOND, THIRD, FOURTH, FIFTH

studentNames

Array

true

학생 이름

professorNames

Array

true

교수 이름

likeCount

Number

true

좋아요 수

like

Boolean

true

좋아요 여부

bookMark

Boolean

true

북마크 여부

url

String

true

프로젝트 URL

description

String

true

프로젝트 설명

comments

Array

true

댓글

comments[].id

Number

true

댓글 ID

comments[].projectId

Number

true

유저 ID

comments[].userName

String

true

유저 이름

comments[].isAnonymous

Boolean

true

익명 여부

comments[].content

String

true

댓글 내용

comments[].createdAt

String

true

생성 시간

comments[].updatedAt

String

true

수정 시간

+
+
+
+
+
+

프로젝트 수정 (PUT /projects/{projectId})

+
+
+
+

HTTP request

+
+
+
PUT /projects/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Content-Length: 530
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "thumbnailId" : 3,
+  "posterId" : 4,
+  "projectName" : "프로젝트 이름",
+  "projectType" : "LAB",
+  "projectCategory" : "COMPUTER_VISION",
+  "teamName" : "팀 이름",
+  "youtubeId" : "유튜브 ID",
+  "year" : 2024,
+  "awardStatus" : "FIRST",
+  "members" : [ {
+    "name" : "학생 이름 3",
+    "role" : "STUDENT"
+  }, {
+    "name" : "학생 이름 4",
+    "role" : "STUDENT"
+  }, {
+    "name" : "교수 이름 2",
+    "role" : "PROFESSOR"
+  } ],
+  "url" : "프로젝트 URL",
+  "description" : "프로젝트 설명"
+}
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 1477
+
+{
+  "id" : 1,
+  "thumbnailInfo" : {
+    "id" : 3,
+    "uuid" : "썸네일 uuid",
+    "name" : "썸네일 파일 이름",
+    "mimeType" : "썸네일 mime 타입"
+  },
+  "posterInfo" : {
+    "id" : 4,
+    "uuid" : "포스터 uuid",
+    "name" : "포트서 파일 이름",
+    "mimeType" : "포스터 mime 타입"
+  },
+  "projectName" : "프로젝트 이름",
+  "projectType" : "LAB",
+  "projectCategory" : "COMPUTER_VISION",
+  "teamName" : "팀 이름",
+  "youtubeId" : "유튜브 ID",
+  "year" : 2024,
+  "awardStatus" : "FIRST",
+  "studentNames" : [ "학생 이름 3", "학생 이름 4" ],
+  "professorNames" : [ "교수 이름 2" ],
+  "likeCount" : 100,
+  "like" : false,
+  "bookMark" : false,
+  "comments" : [ {
+    "id" : 1,
+    "projectId" : 1,
+    "userName" : "유저 이름",
+    "isAnonymous" : true,
+    "content" : "댓글 내용",
+    "createdAt" : "2024-11-28T19:49:20.940585",
+    "updatedAt" : "2024-11-28T19:49:20.940587"
+  }, {
+    "id" : 2,
+    "projectId" : 1,
+    "userName" : "유저 이름",
+    "isAnonymous" : false,
+    "content" : "댓글 내용",
+    "createdAt" : "2024-11-28T19:49:20.940592",
+    "updatedAt" : "2024-11-28T19:49:20.940593"
+  }, {
+    "id" : 3,
+    "projectId" : 1,
+    "userName" : "유저 이름",
+    "isAnonymous" : false,
+    "content" : "댓글 내용",
+    "createdAt" : "2024-11-28T19:49:20.940595",
+    "updatedAt" : "2024-11-28T19:49:20.940596"
+  } ],
+  "url" : "프로젝트 URL",
+  "description" : "프로젝트 설명"
+}
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /projects/{projectId}
ParameterDescription

projectId

프로젝트 ID

+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

thumbnailId

Number

true

썸네일 ID

posterId

Number

true

포스터 ID

projectName

String

true

프로젝트 이름

projectType

String

true

프로젝트 타입: RESEARCH_AND_BUSINESS_FOUNDATION, LAB, STARTUP, CLUB

projectCategory

String

true

프로젝트 카테고리: COMPUTER_VISION, SYSTEM_NETWORK, WEB_APPLICATION, SECURITY_SOFTWARE_ENGINEERING, NATURAL_LANGUAGE_PROCESSING, BIG_DATA_ANALYSIS, AI_MACHINE_LEARNING, INTERACTION_AUGMENTED_REALITY

teamName

String

true

팀 이름

youtubeId

String

true

프로젝트 youtubeId

year

Number

true

프로젝트 년도

awardStatus

String

true

수상 여부: NONE, FIRST, SECOND, THIRD, FOURTH, FIFTH

members

Array

true

멤버

members[].name

String

true

멤버 이름

members[].role

String

true

멤버 역할: STUDENT, PROFESSOR

url

String

true

프로젝트 URL

description

String

true

프로젝트 설명

+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

프로젝트 ID

thumbnailInfo

Object

true

썸네일 정보

thumbnailInfo.id

Number

true

썸네일 ID

thumbnailInfo.uuid

String

true

썸네일 UUID

thumbnailInfo.name

String

true

썸네일 파일 이름

thumbnailInfo.mimeType

String

true

썸네일 MIME 타입

posterInfo

Object

true

포스터 정보

posterInfo.id

Number

true

포스터 ID

posterInfo.uuid

String

true

포스터 UUID

posterInfo.name

String

true

포스터 파일 이름

posterInfo.mimeType

String

true

포스터 MIME 타입

projectName

String

true

프로젝트 이름

projectType

String

true

프로젝트 타입: RESEARCH_AND_BUSINESS_FOUNDATION, LAB, STARTUP, CLUB

projectCategory

String

true

프로젝트 카테고리: COMPUTER_VISION, SYSTEM_NETWORK, WEB_APPLICATION, SECURITY_SOFTWARE_ENGINEERING, NATURAL_LANGUAGE_PROCESSING, BIG_DATA_ANALYSIS, AI_MACHINE_LEARNING, INTERACTION_AUGMENTED_REALITY

teamName

String

true

팀 이름

youtubeId

String

true

프로젝트 youtubeId

year

Number

true

프로젝트 년도

awardStatus

String

true

수상 여부: NONE, FIRST, SECOND, THIRD, FOURTH, FIFTH

studentNames

Array

true

학생 이름

professorNames

Array

true

교수 이름

likeCount

Number

true

좋아요 수

like

Boolean

true

좋아요 여부

bookMark

Boolean

true

북마크 여부

url

String

true

프로젝트 URL

description

String

true

프로젝트 설명

comments

Array

true

댓글

comments[].id

Number

true

댓글 ID

comments[].projectId

Number

true

유저 ID

comments[].userName

String

true

유저 이름

comments[].isAnonymous

Boolean

true

익명 여부

comments[].content

String

true

댓글 내용

comments[].createdAt

String

true

생성 시간

comments[].updatedAt

String

true

수정 시간

+
+
+
+
+
+

프로젝트 삭제 (DELETE /projects/{projectId})

+
+
+
+

HTTP request

+
+
+
DELETE /projects/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /projects/{projectId}
ParameterDescription

projectId

프로젝트 ID

+
+
+
+
+
+

관심 프로젝트 등록 (POST /projects/{projectId}/favorite)

+
+
+
+

HTTP request

+
+
+
POST /projects/1/favorite HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: all_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /projects/{projectId}/favorite
ParameterDescription

projectId

프로젝트 ID

+
+
+
+
+
+

관심 프로젝트 삭제 (DELETE /projects/{projectId}/favorite)

+
+
+
+

HTTP request

+
+
+
DELETE /projects/1/favorite HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: all_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /projects/{projectId}/favorite
ParameterDescription

projectId

프로젝트 ID

+
+
+
+
+
+

프로젝트 좋아요 등록 (POST /projects/{projectId}/like)

+
+
+
+

HTTP request

+
+
+
POST /projects/1/like HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: all_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /projects/{projectId}/like
ParameterDescription

projectId

프로젝트 ID

+
+
+
+
+
+

프로젝트 좋아요 삭제 (DELETE /projects/{projectId}/like)

+
+
+
+

HTTP request

+
+
+
DELETE /projects/1/like HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: all_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /projects/{projectId}/like
ParameterDescription

projectId

프로젝트 ID

+
+
+
+
+
+

프로젝트 댓글 등록 (POST /projects/{projectId}/comment)

+
+
+
+

HTTP request

+
+
+
POST /projects/1/comment HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: all_access_token
+Content-Length: 57
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "content" : "댓글 내용",
+  "isAnonymous" : true
+}
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 211
+
+{
+  "id" : 1,
+  "projectId" : 1,
+  "userName" : "유저 이름",
+  "isAnonymous" : true,
+  "content" : "댓글 내용",
+  "createdAt" : "2024-11-28T19:49:21.02064",
+  "updatedAt" : "2024-11-28T19:49:21.020642"
+}
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /projects/{projectId}/comment
ParameterDescription

projectId

프로젝트 ID

+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

content

String

true

댓글 내용

isAnonymous

Boolean

true

익명 여부

+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

댓글 ID

projectId

Number

true

프로젝트 ID

userName

String

true

유저 이름

isAnonymous

Boolean

true

익명 여부

content

String

true

댓글 내용

createdAt

String

true

생성 시간

updatedAt

String

true

수정 시간

+
+
+
+
+
+

프로젝트 댓글 삭제 (DELETE /projects/{projectId}/comment)

+
+
+
+

HTTP request

+
+
+
DELETE /projects/1/comment/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: all_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + + + + + +
Table 1. /projects/{projectId}/comment/{commentId}
ParameterDescription

projectId

프로젝트 ID

commentId

댓글 ID

+
+
+
+
+
+

수상 프로젝트 조회 (GET /projects/award?year={year})

+
+
+
+

HTTP request

+
+
+
GET /projects/award?year=2024 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: optional_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 1759
+
+{
+  "totalPages" : 1,
+  "totalElements" : 2,
+  "first" : true,
+  "last" : true,
+  "size" : 10,
+  "content" : [ {
+    "id" : 1,
+    "thumbnailInfo" : {
+      "id" : 1,
+      "uuid" : "썸네일 uuid 1",
+      "name" : "썸네일 파일 이름 1",
+      "mimeType" : "썸네일 mime 타입 1"
+    },
+    "projectName" : "프로젝트 이름 1",
+    "teamName" : "팀 이름 1",
+    "studentNames" : [ "학생 이름 1", "학생 이름 2" ],
+    "professorNames" : [ "교수 이름 1" ],
+    "projectType" : "STARTUP",
+    "projectCategory" : "BIG_DATA_ANALYSIS",
+    "awardStatus" : "FIRST",
+    "year" : 2023,
+    "likeCount" : 100,
+    "like" : false,
+    "bookMark" : false,
+    "url" : "프로젝트 URL",
+    "description" : "프로젝트 설명"
+  }, {
+    "id" : 2,
+    "thumbnailInfo" : {
+      "id" : 2,
+      "uuid" : "썸네일 uuid 2",
+      "name" : "썸네일 파일 이름 2",
+      "mimeType" : "썸네일 mime 타입 2"
+    },
+    "projectName" : "프로젝트 이름 2",
+    "teamName" : "팀 이름 2",
+    "studentNames" : [ "학생 이름 3", "학생 이름 4" ],
+    "professorNames" : [ "교수 이름 2" ],
+    "projectType" : "LAB",
+    "projectCategory" : "AI_MACHINE_LEARNING",
+    "awardStatus" : "SECOND",
+    "year" : 2023,
+    "likeCount" : 100,
+    "like" : false,
+    "bookMark" : true,
+    "url" : "프로젝트 URL",
+    "description" : "프로젝트 설명"
+  } ],
+  "number" : 0,
+  "sort" : {
+    "empty" : true,
+    "sorted" : false,
+    "unsorted" : true
+  },
+  "numberOfElements" : 2,
+  "pageable" : {
+    "pageNumber" : 0,
+    "pageSize" : 10,
+    "sort" : {
+      "empty" : true,
+      "sorted" : false,
+      "unsorted" : true
+    },
+    "offset" : 0,
+    "paged" : true,
+    "unpaged" : false
+  },
+  "empty" : false
+}
+
+
+
+
+

Query parameters

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterRequiredDescription

year

true

프로젝트 년도

page

false

페이지 번호 [default: 0]

size

false

페이지 크기 [default: 10]

sort

false

정렬 기준

+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

content[].id

Number

true

프로젝트 ID

content[].thumbnailInfo

Object

true

썸네일 정보

content[].thumbnailInfo.id

Number

true

썸네일 ID

content[].thumbnailInfo.uuid

String

true

썸네일 UUID

content[].thumbnailInfo.name

String

true

썸네일 파일 이름

content[].thumbnailInfo.mimeType

String

true

썸네일 MIME 타입

content[].projectName

String

true

프로젝트 이름

content[].teamName

String

true

팀 이름

content[].studentNames[]

Array

true

학생 이름

content[].professorNames[]

Array

true

교수 이름

content[].projectType

String

true

프로젝트 타입: RESEARCH_AND_BUSINESS_FOUNDATION, LAB, STARTUP, CLUB

content[].projectCategory

String

true

프로젝트 카테고리: COMPUTER_VISION, SYSTEM_NETWORK, WEB_APPLICATION, SECURITY_SOFTWARE_ENGINEERING, NATURAL_LANGUAGE_PROCESSING, BIG_DATA_ANALYSIS, AI_MACHINE_LEARNING, INTERACTION_AUGMENTED_REALITY

content[].awardStatus

String

true

수상 여부: NONE, FIRST, SECOND, THIRD, FOURTH, FIFTH

content[].year

Number

true

프로젝트 년도

content[].likeCount

Number

true

좋아요 수

content[].like

Boolean

true

좋아요 여부

content[].bookMark

Boolean

true

북마크 여부

content[].url

String

true

프로젝트 URL

content[].description

String

true

프로젝트 설명

pageable

Object

true

페이지 정보

pageable.pageNumber

Number

true

현재 페이지 번호

pageable.pageSize

Number

true

페이지 당 요소 수

pageable.sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

pageable.sort.sorted

Boolean

true

정렬된 상태인지 여부

pageable.sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

pageable.offset

Number

true

오프셋

pageable.paged

Boolean

true

페이징된 여부

pageable.unpaged

Boolean

true

페이징되지 않은 여부

totalElements

Number

true

전체 요소 수

totalPages

Number

true

전체 페이지 수

size

Number

true

페이지 당 요소 수

number

Number

true

현재 페이지 번호

numberOfElements

Number

true

현재 페이지 요소 수

first

Boolean

true

첫 페이지 여부

last

Boolean

true

마지막 페이지 여부

sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

sort.sorted

Boolean

true

정렬된 상태인지 여부

empty

Boolean

true

비어있는 페이지 여부

+
+
+
+
+
+
+
+

프로젝트 문의 사항 API

+
+
+
+

프로젝트 문의 사항 생성 (POST /projects/{projectId}/inquiry)

+
+
+
+

HTTP request

+
+
+
POST /projects/1/inquiry HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: company_access_token
+Content-Length: 102
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "title" : "프로젝트 문의 사항 제목",
+  "content" : "프로젝트 문의 사항 내용"
+}
+
+
+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

문의 사항 제목

content

String

true

문의 사항 내용

+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 305
+
+{
+  "id" : 1,
+  "authorName" : "문의 작성자 이름",
+  "projectId" : 1,
+  "projectName" : "프로젝트 이름",
+  "title" : "문의 사항 제목",
+  "content" : "문의 사항 내용",
+  "replied" : false,
+  "createdAt" : "2024-11-28T19:49:20.275734",
+  "updatedAt" : "2024-11-28T19:49:20.275739"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

문의 사항 ID

authorName

String

true

문의 작성자 이름

projectId

Number

true

문의 대상 프로젝트 ID

projectName

String

true

문의 대상 프로젝트 이름

title

String

true

문의 사항 제목

content

String

true

문의 사항 내용

replied

Boolean

true

답변 등록 여부

createdAt

String

true

문의 사항 생성 시간

updatedAt

String

true

문의 사항 수정 시간

+
+
+
+
+
+
+

프로젝트 문의 사항 리스트 조회 (GET /inquiries)

+
+
+
+

HTTP request

+
+
+
GET /inquiries?terms=inquiry&scope=title&page=0&size=10 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: company_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Query parameters

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterRequiredDescription

terms

false

검색어

scope

false

검색 범위 (title, content, both, author) [default: both, searchTerm=null 이면 null로 초기화]

page

false

페이지 번호 [default: 0]

size

false

페이지 크기 [default: 10]

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 833
+
+{
+  "totalPages" : 1,
+  "totalElements" : 2,
+  "first" : true,
+  "last" : true,
+  "size" : 10,
+  "content" : [ {
+    "id" : 1,
+    "authorName" : "프로젝트 inquiry (문의) 제목 1",
+    "title" : "프로젝트 문의 사항 내용",
+    "createdAt" : "2024-11-28T19:49:20.236706"
+  }, {
+    "id" : 2,
+    "authorName" : "프로젝트 inquiry (문의) 제목 2",
+    "title" : "프로젝트 문의 사항 내용",
+    "createdAt" : "2024-11-28T19:49:20.236735"
+  } ],
+  "number" : 0,
+  "sort" : {
+    "empty" : true,
+    "sorted" : false,
+    "unsorted" : true
+  },
+  "numberOfElements" : 2,
+  "pageable" : {
+    "pageNumber" : 0,
+    "pageSize" : 10,
+    "sort" : {
+      "empty" : true,
+      "sorted" : false,
+      "unsorted" : true
+    },
+    "offset" : 0,
+    "paged" : true,
+    "unpaged" : false
+  },
+  "empty" : false
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

content[].id

Number

true

문의 사항 ID

content[].authorName

String

true

문의 작성자 이름

content[].title

String

true

문의 사항 제목

content[].createdAt

String

true

문의 사항 생성 시간

pageable.pageNumber

Number

true

현재 페이지 번호

pageable.pageSize

Number

true

페이지 당 요소 수

pageable.sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

pageable.sort.sorted

Boolean

true

정렬된 상태인지 여부

pageable.sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

pageable.offset

Number

true

오프셋

pageable.paged

Boolean

true

페이징된 여부

pageable.unpaged

Boolean

true

페이징되지 않은 여부

totalElements

Number

true

전체 요소 수

totalPages

Number

true

전체 페이지 수

size

Number

true

페이지 당 요소 수

number

Number

true

현재 페이지 번호

numberOfElements

Number

true

현재 페이지 요소 수

first

Boolean

true

첫 페이지 여부

last

Boolean

true

마지막 페이지 여부

sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

sort.sorted

Boolean

true

정렬된 상태인지 여부

empty

Boolean

true

비어있는 페이지 여부

+
+
+
+
+
+
+

프로젝트 문의 사항 단건 조회 (GET /inquiries/{inquiryId})

+
+
+
+

HTTP request

+
+
+
GET /inquiries/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: company_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /inquiries/{inquiryId}
ParameterDescription

inquiryId

조회할 문의 사항 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 305
+
+{
+  "id" : 1,
+  "authorName" : "문의 작성자 이름",
+  "projectId" : 1,
+  "projectName" : "프로젝트 이름",
+  "title" : "문의 사항 제목",
+  "content" : "문의 사항 내용",
+  "replied" : false,
+  "createdAt" : "2024-11-28T19:49:20.260492",
+  "updatedAt" : "2024-11-28T19:49:20.260496"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

문의 사항 ID

authorName

String

true

문의 작성자 이름

projectId

Number

true

문의 대상 프로젝트 ID

projectName

String

true

문의 대상 프로젝트 이름

title

String

true

문의 사항 제목

content

String

true

문의 사항 내용

replied

Boolean

true

답변 등록 여부

createdAt

String

true

문의 사항 생성 시간

updatedAt

String

true

문의 사항 수정 시간

+
+
+
+
+
+
+

프로젝트 문의 사항 수정 (PUT /inquiries/{inquiryId})

+
+
+
+

HTTP request

+
+
+
PUT /inquiries/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: company_access_token
+Content-Length: 122
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "title" : "수정된 프로젝트 문의 사항 제목",
+  "content" : "수정된 프로젝트 문의 사항 내용"
+}
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /inquiries/{inquiryId}
ParameterDescription

inquiryId

수정할 문의 사항 ID

+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

문의 사항 제목

content

String

true

문의 사항 내용

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 325
+
+{
+  "id" : 1,
+  "authorName" : "문의 작성자 이름",
+  "projectId" : 1,
+  "projectName" : "프로젝트 이름",
+  "title" : "수정된 문의 사항 제목",
+  "content" : "수정된 문의 사항 내용",
+  "replied" : false,
+  "createdAt" : "2024-11-28T19:49:20.296233",
+  "updatedAt" : "2024-11-28T19:49:20.296237"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

문의 사항 ID

authorName

String

true

문의 작성자 이름

projectId

Number

true

문의 대상 프로젝트 ID

projectName

String

true

문의 대상 프로젝트 이름

title

String

true

수정된 문의 사항 제목

content

String

true

수정된 문의 사항 내용

replied

Boolean

true

답변 등록 여부

createdAt

String

true

문의 사항 생성 시간

updatedAt

String

true

문의 사항 수정 시간

+
+
+
+
+
+
+

프로젝트 문의 사항 삭제 (DELETE /inquiries/{inquiryId})

+
+
+
+

HTTP request

+
+
+
DELETE /inquiries/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: company_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /inquiries/{inquiryId}
ParameterDescription

inquiryId

삭제할 문의 사항 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+
+
+

프로젝트 문의 사항 답변 (POST /inquiries/{inquiryId}/reply)

+
+
+
+

HTTP request

+
+
+
POST /inquiries/1/reply HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Content-Length: 76
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "title" : "문의 답변 제목",
+  "content" : "문의 답변 내용"
+}
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /inquiries/{inquiryId}/reply
ParameterDescription

inquiryId

답변할 문의 ID

+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

답변 제목

content

String

true

답변 내용

+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 88
+
+{
+  "id" : 1,
+  "title" : "문의 답변 제목",
+  "content" : "문의 답변 내용"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

답변 ID

title

String

true

답변 제목

content

String

true

답변 내용

+
+
+
+
+
+
+

프로젝트 문의 사항 답변 조회 (GET /inquiries/{inquiryId}/reply)

+
+
+
+

HTTP request

+
+
+
GET /inquiries/1/reply HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: company_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /inquiries/{inquiryId}/reply
ParameterDescription

inquiryId

조회할 문의 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 88
+
+{
+  "id" : 1,
+  "title" : "문의 답변 제목",
+  "content" : "문의 답변 내용"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

답변 ID

title

String

true

답변 제목

content

String

true

답변 내용

+
+
+
+
+
+
+

프로젝트 문의 사항 답변 수정 (PUT /inquiries/{inquiryId}/reply)

+
+
+
+

HTTP request

+
+
+
PUT /inquiries/1/reply HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Content-Length: 96
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "title" : "수정된 문의 답변 제목",
+  "content" : "수정된 문의 답변 내용"
+}
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /inquiries/{inquiryId}/reply
ParameterDescription

inquiryId

수정할 답변 ID

+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

답변 제목

content

String

true

답변 내용

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 108
+
+{
+  "id" : 1,
+  "title" : "수정된 문의 답변 제목",
+  "content" : "수정된 문의 답변 내용"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

답변 ID

title

String

true

수정된 답변 제목

content

String

true

수정된 답변 내용

+
+
+
+
+
+
+

프로젝트 문의 사항 답변 삭제 (DELETE /inquiries/{inquiryId}/reply)

+
+
+
+

HTTP request

+
+
+
DELETE /inquiries/1/reply HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /inquiries/{inquiryId}/reply
ParameterDescription

inquiryId

삭제할 답변 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+
+
+
+

과제 제안 API

+
+
+

과제 제안 생성 (POST /proposals)

+
+
+
+

HTTP request

+
+
+
POST /proposals HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_or_company_access_token
+Content-Length: 247
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "webSite" : "website.com",
+  "title" : "과제제안제목",
+  "email" : "이메일@email.com",
+  "projectTypes" : [ "LAB", "CLUB" ],
+  "content" : "과제제안내용",
+  "isVisible" : true,
+  "isAnonymous" : true,
+  "fileIds" : [ 1, 2, 3 ]
+}
+
+
+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

과제제안 제목

content

String

true

과제제안 내용

webSite

String

false

과제제안 사이트

projectTypes

Array

true

과제제안 프로젝트 유형:RESEARCH_AND_BUSINESS_FOUNDATION, LAB, STARTUP, CLUB

email

String

true

과제제안 이메일

isVisible

Boolean

true

공개 여부

isAnonymous

Boolean

true

익명 여부

fileIds

Array

true

파일 id 리스트

+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 906
+
+{
+  "id" : 1,
+  "authorName" : "작성자",
+  "email" : "이메일@email.com",
+  "webSite" : "website.com",
+  "title" : "과제제안제목",
+  "projectTypes" : [ "LAB", "CLUB" ],
+  "content" : "과제제안내용",
+  "replied" : false,
+  "files" : [ {
+    "id" : 1,
+    "uuid" : "uuid1",
+    "name" : "과제제안서 이름1.pdf",
+    "mimeType" : "application/pdf",
+    "createdAt" : "2024-11-28T19:49:21.437367",
+    "updatedAt" : "2024-11-28T19:49:21.43737"
+  }, {
+    "id" : 2,
+    "uuid" : "uuid2",
+    "name" : "과제제안서 이름2.pdf",
+    "mimeType" : "application/pdf",
+    "createdAt" : "2024-11-28T19:49:21.437371",
+    "updatedAt" : "2024-11-28T19:49:21.437372"
+  }, {
+    "id" : 3,
+    "uuid" : "uuid3",
+    "name" : "과제제안서 이름3.pdf",
+    "mimeType" : "application/pdf",
+    "createdAt" : "2024-11-28T19:49:21.437372",
+    "updatedAt" : "2024-11-28T19:49:21.437373"
+  } ]
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

과제 제안 ID

authorName

String

true

과제 제안 작성자

email

String

true

과제 제안 이메일

webSite

String

true

과제 제안 사이트

title

String

true

과제 제안 제목

projectTypes

Array

true

과제 제안 프로젝트 유형들

content

String

true

과제 제안 내용

replied

Boolean

true

과제 제안 답변 유무

files

Array

true

파일 목록

files[].id

Number

true

파일 ID

files[].uuid

String

true

파일 UUID

files[].name

String

true

파일 이름

files[].mimeType

String

true

파일 MIME 타입

files[].createdAt

String

true

파일 생성일

files[].updatedAt

String

true

파일 수정일

+
+
+
+
+
+

과제 제안 리스트 조회 (GET /proposals)

+
+
+
+

Query parameters

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterRequiredDescription

scope

false

필터 조건 (title,content,both,author)

term

false

필터 키워드

page

false

페이지 번호 [default = 0]

size

false

페이지 크기 [default = 10]

+
+
+

HTTP request

+
+
+
GET /proposals HTTP/1.1
+Authorization: all_user_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 723
+
+{
+  "totalPages" : 1,
+  "totalElements" : 2,
+  "first" : true,
+  "last" : true,
+  "size" : 10,
+  "content" : [ {
+    "id" : 1,
+    "title" : "과제 제안1",
+    "name" : "이름",
+    "createdDate" : "2024-11-28T19:49:21.399833"
+  }, {
+    "id" : 2,
+    "title" : "과제 제안2",
+    "name" : "이름",
+    "createdDate" : "2024-11-28T19:49:21.399862"
+  } ],
+  "number" : 0,
+  "sort" : {
+    "empty" : true,
+    "sorted" : false,
+    "unsorted" : true
+  },
+  "numberOfElements" : 2,
+  "pageable" : {
+    "pageNumber" : 0,
+    "pageSize" : 10,
+    "sort" : {
+      "empty" : true,
+      "sorted" : false,
+      "unsorted" : true
+    },
+    "offset" : 0,
+    "paged" : true,
+    "unpaged" : false
+  },
+  "empty" : false
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

content[].id

Number

true

과제 제안 ID

content[].title

String

true

과제 제안 제목

content[].name

String

true

과제 제안 작성자

content[].createdDate

String

true

과제 제안 생성일

pageable

Object

true

페이지 정보

pageable.pageNumber

Number

true

현재 페이지 번호

pageable.pageSize

Number

true

페이지 당 요소 수

pageable.sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

pageable.sort.sorted

Boolean

true

정렬된 상태인지 여부

pageable.sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

pageable.offset

Number

true

오프셋

pageable.paged

Boolean

true

페이징된 여부

pageable.unpaged

Boolean

true

페이징되지 않은 여부

totalElements

Number

true

전체 요소 수

totalPages

Number

true

전체 페이지 수

size

Number

true

페이지 당 요소 수

number

Number

true

현재 페이지 번호

numberOfElements

Number

true

현재 페이지 요소 수

first

Boolean

true

첫 페이지 여부

last

Boolean

true

마지막 페이지 여부

sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

sort.sorted

Boolean

true

정렬된 상태인지 여부

empty

Boolean

true

비어있는 페이지 여부

+
+
+
+
+
+

과제 제안 상세 조회 (GET /proposals/{proposalId})

+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /proposals/{proposalId}
ParameterDescription

proposalId

수정할 과제제안 ID

+
+
+

HTTP request

+
+
+
GET /proposals/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_or_company_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 955
+
+{
+  "id" : 1,
+  "authorName" : "작성자",
+  "email" : "작성자@email.com",
+  "webSite" : "website.com",
+  "title" : "과제 제안 제목",
+  "projectTypes" : [ "LAB", "CLUB", "STARTUP", "RESEARCH_AND_BUSINESS_FOUNDATION" ],
+  "content" : "과제제안 내용",
+  "replied" : false,
+  "files" : [ {
+    "id" : 1,
+    "uuid" : "uuid1",
+    "name" : "과제제안서 이름1.pdf",
+    "mimeType" : "application/pdf",
+    "createdAt" : "2024-11-28T19:49:21.445892",
+    "updatedAt" : "2024-11-28T19:49:21.445894"
+  }, {
+    "id" : 2,
+    "uuid" : "uuid2",
+    "name" : "과제제안서 이름2.pdf",
+    "mimeType" : "application/pdf",
+    "createdAt" : "2024-11-28T19:49:21.445897",
+    "updatedAt" : "2024-11-28T19:49:21.445898"
+  }, {
+    "id" : 3,
+    "uuid" : "uuid3",
+    "name" : "과제제안서 이름3.pdf",
+    "mimeType" : "application/pdf",
+    "createdAt" : "2024-11-28T19:49:21.445899",
+    "updatedAt" : "2024-11-28T19:49:21.4459"
+  } ]
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

과제 제안 ID

authorName

String

true

과제 제안 작성자

email

String

true

과제 제안 이메일

webSite

String

true

과제 제안 사이트

title

String

true

과제 제안 제목

projectTypes

Array

true

과제 제안 프로젝트 유형들

content

String

true

과제 제안 내용

replied

Boolean

true

과제 제안 답변 유무

files

Array

true

파일 목록

files[].id

Number

true

파일 ID

files[].uuid

String

true

파일 UUID

files[].name

String

true

파일 이름

files[].mimeType

String

true

파일 MIME 타입

files[].createdAt

String

true

파일 생성일

files[].updatedAt

String

true

파일 수정일

+
+
+
+
+
+

과제 제안 수정 (PUT /proposals/{proposalId})

+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /proposals/{proposalId}
ParameterDescription

proposalId

수정할 과제제안 ID

+
+
+

HTTP request

+
+
+
PUT /proposals/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_or_company_access_token
+Content-Length: 247
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "webSite" : "website.com",
+  "title" : "과제제안제목",
+  "email" : "이메일@email.com",
+  "projectTypes" : [ "LAB", "CLUB" ],
+  "content" : "과제제안내용",
+  "isVisible" : true,
+  "isAnonymous" : true,
+  "fileIds" : [ 1, 2, 3 ]
+}
+
+
+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

과제제안 제목

content

String

true

과제제안 내용

webSite

String

false

과제제안 사이트

projectTypes

Array

true

과제제안 프로젝트 유형:RESEARCH_AND_BUSINESS_FOUNDATION, LAB, STARTUP, CLUB

email

String

true

과제제안 이메일

isVisible

Boolean

true

공개 여부

isAnonymous

Boolean

true

익명 여부

fileIds

Array

true

파일 id 리스트

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 906
+
+{
+  "id" : 1,
+  "authorName" : "작성자",
+  "email" : "이메일@email.com",
+  "webSite" : "website.com",
+  "title" : "과제제안제목",
+  "projectTypes" : [ "LAB", "CLUB" ],
+  "content" : "과제제안내용",
+  "replied" : false,
+  "files" : [ {
+    "id" : 1,
+    "uuid" : "uuid1",
+    "name" : "과제제안서 이름1.pdf",
+    "mimeType" : "application/pdf",
+    "createdAt" : "2024-11-28T19:49:21.423858",
+    "updatedAt" : "2024-11-28T19:49:21.42386"
+  }, {
+    "id" : 2,
+    "uuid" : "uuid2",
+    "name" : "과제제안서 이름2.pdf",
+    "mimeType" : "application/pdf",
+    "createdAt" : "2024-11-28T19:49:21.423864",
+    "updatedAt" : "2024-11-28T19:49:21.423865"
+  }, {
+    "id" : 3,
+    "uuid" : "uuid3",
+    "name" : "과제제안서 이름3.pdf",
+    "mimeType" : "application/pdf",
+    "createdAt" : "2024-11-28T19:49:21.423867",
+    "updatedAt" : "2024-11-28T19:49:21.423868"
+  } ]
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

과제 제안 ID

authorName

String

true

과제 제안 작성자

email

String

true

과제 제안 이메일

webSite

String

true

과제 제안 사이트

title

String

true

과제 제안 제목

projectTypes

Array

true

과제 제안 프로젝트 유형들

content

String

true

과제 제안 내용

replied

Boolean

true

과제 제안 답변 유무

files

Array

true

파일 목록

files[].id

Number

true

파일 ID

files[].uuid

String

true

파일 UUID

files[].name

String

true

파일 이름

files[].mimeType

String

true

파일 MIME 타입

files[].createdAt

String

true

파일 생성일

files[].updatedAt

String

true

파일 수정일

+
+
+
+
+
+

과제 제안 삭제 (DELETE /proposals/{proposalId})

+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /proposals/{proposalId}
ParameterDescription

proposalId

삭제할 과제제안 ID

+
+
+

HTTP request

+
+
+
DELETE /proposals/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_or_company_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+
+

과제 제안 답변 조회 (GET /proposals/{proposalId}/reply)

+
+
+
+

Query parameters

+
+

Snippet query-parameters not found for operation::proposal-controller-test/get-proposal-replies

+
+
+
+

HTTP request

+
+
+
GET /proposals/1/reply HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_or_company_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 100
+
+{
+  "id" : 1,
+  "title" : "과제제안 답변 제목",
+  "content" : "과제제안 답변 내용"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

과제 제안 답변 ID

title

String

true

과제 제안 답변 제목

content

String

true

과제 제안 답변 내용

+
+
+
+
+
+

과제 제안 답변 생성 (PUT /proposals/{proposalId}/reply/)

+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /proposals/{proposalId}/reply
ParameterDescription

proposalId

해당 과제제안 ID

+
+
+

HTTP request

+
+
+
POST /proposals/1/reply HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_or_company_access_token
+Content-Length: 62
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "title" : "답변 제목",
+  "content" : "답변 내용"
+}
+
+
+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

과제제안 답변 제목

content

String

true

과제제안 답변 내용

+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 74
+
+{
+  "id" : 1,
+  "title" : "답변 제목",
+  "content" : "답변 내용"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

과제 제안 답변 ID

title

String

true

과제 제안 답변 제목

content

String

true

과제 제안 답변 내용

+
+
+
+
+
+

과제 제안 답변 수정 (PUT /proposals/{proposalId}/reply/{replyId})

+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + + + + + +
Table 1. /proposals/{proposalId}/reply/{replyId}
ParameterDescription

proposalId

해당 과제제안 ID

replyId

과제 제안 답변 ID

+
+
+

HTTP request

+
+
+
PUT /proposals/1/reply/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_or_company_access_token
+Content-Length: 62
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "title" : "답변 제목",
+  "content" : "답변 내용"
+}
+
+
+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

과제제안 답변 제목

content

String

true

과제제안 답변 내용

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 74
+
+{
+  "id" : 1,
+  "title" : "답변 제목",
+  "content" : "답변 내용"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

과제 제안 답변 ID

title

String

true

과제 제안 답변 제목

content

String

true

과제 제안 답변 내용

+
+
+
+
+
+

과제 제안 답변 삭제 (DELETE /proposals/{proposalId}/reply/{replyId})

+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + + + + + +
Table 1. /proposals/{proposalId}/reply/{proposalReplyId}
ParameterDescription

proposalId

해당 과제제안 ID

proposalReplyId

삭제할 과제제안 답변 ID

+
+
+

HTTP request

+
+
+
DELETE /proposals/1/reply/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+
+
+
+

AI HUB API

+
+
+
+

AI HUB 모델 리스트 조회 (POST /aihub/models)

+
+
+
+

Query parameters

+ +++++ + + + + + + + + + + + + + + + + + + + +
ParameterRequiredDescription

page

false

페이지 번호 [default: 0]

size

false

페이지 크기 [default: 10]

+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

AI 모델 제목

learningModels

Array

true

학습 모델

topics

Array

true

주제 분류

developmentYears

Array

true

개발 년도

professor

String

true

담당 교수

participants

Array

true

참여 학생

+
+
+

HTTP request

+
+
+
POST /aihub/models HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Content-Length: 199
+Host: localhost:8080
+
+{
+  "title" : "title",
+  "learningModels" : [ "학습 모델 1" ],
+  "topics" : [ "주제 1" ],
+  "developmentYears" : [ 2024 ],
+  "professor" : "담당 교수 1",
+  "participants" : [ "학생 1" ]
+}
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 1221
+
+{
+  "totalPages" : 1,
+  "totalElements" : 2,
+  "first" : true,
+  "last" : true,
+  "size" : 10,
+  "content" : [ {
+    "title" : "title",
+    "learningModels" : [ "학습 모델 1" ],
+    "topics" : [ "주제 1" ],
+    "developmentYears" : [ 2024 ],
+    "professor" : "담당 교수 1",
+    "participants" : [ "학생 1", "학생 2" ],
+    "object" : "page",
+    "id" : "노션 object 아이디",
+    "cover" : "커버 이미지 link",
+    "url" : "노션 redirect url"
+  }, {
+    "title" : "title",
+    "learningModels" : [ "학습 모델 1" ],
+    "topics" : [ "주제 1", "주제 2" ],
+    "developmentYears" : [ 2024 ],
+    "professor" : "담당 교수 1",
+    "participants" : [ "학생 1", "학생 2", "학생 3" ],
+    "object" : "page",
+    "id" : "노션 object 아이디",
+    "cover" : "커버 이미지 link",
+    "url" : "노션 redirect url"
+  } ],
+  "number" : 0,
+  "sort" : {
+    "empty" : true,
+    "sorted" : false,
+    "unsorted" : true
+  },
+  "numberOfElements" : 2,
+  "pageable" : {
+    "pageNumber" : 0,
+    "pageSize" : 10,
+    "sort" : {
+      "empty" : true,
+      "sorted" : false,
+      "unsorted" : true
+    },
+    "offset" : 0,
+    "paged" : true,
+    "unpaged" : false
+  },
+  "empty" : false
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

content[].object

String

true

object 종류

content[].id

String

true

노션 object 아이디

content[].cover

String

true

커버 이미지 link

content[].title

String

true

AI 모델 제목

content[].learningModels

Array

true

학습 모델

content[].topics

Array

true

주제 분류

content[].developmentYears

Array

true

개발 년도

content[].professor

String

true

담당 교수

content[].participants

Array

true

참여 학생

content[].url

String

true

노션 redirect url

pageable

Object

true

페이지 정보

pageable.pageNumber

Number

true

현재 페이지 번호

pageable.pageSize

Number

true

페이지 당 요소 수

pageable.sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

pageable.sort.sorted

Boolean

true

정렬된 상태인지 여부

pageable.sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

pageable.offset

Number

true

오프셋

pageable.paged

Boolean

true

페이징된 여부

pageable.unpaged

Boolean

true

페이징되지 않은 여부

totalElements

Number

true

전체 요소 수

totalPages

Number

true

전체 페이지 수

size

Number

true

페이지 당 요소 수

number

Number

true

현재 페이지 번호

numberOfElements

Number

true

현재 페이지 요소 수

first

Boolean

true

첫 페이지 여부

last

Boolean

true

마지막 페이지 여부

sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

sort.sorted

Boolean

true

정렬된 상태인지 여부

empty

Boolean

true

비어있는 페이지 여부

+
+
+
+
+
+

AI HUB 데이터셋 리스트 조회 (POST /aihub/datasets)

+
+
+
+

Query parameters

+ +++++ + + + + + + + + + + + + + + + + + + + +
ParameterRequiredDescription

page

false

페이지 번호 [default: 0]

size

false

페이지 크기 [default: 10]

+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

AI 데이터셋 제목

dataTypes

Array

true

데이터 유형

topics

Array

true

주제 분류

developmentYears

Array

true

구축 년도

professor

String

true

담당 교수

participants

Array

true

참여 학생

+
+
+

HTTP request

+
+
+
POST /aihub/datasets HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Content-Length: 197
+Host: localhost:8080
+
+{
+  "title" : "title",
+  "dataTypes" : [ "주제 1" ],
+  "topics" : [ "데이터 유형 1" ],
+  "developmentYears" : [ 2024 ],
+  "professor" : "담당 교수 1",
+  "participants" : [ "학생 1" ]
+}
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 1217
+
+{
+  "totalPages" : 1,
+  "totalElements" : 2,
+  "first" : true,
+  "last" : true,
+  "size" : 10,
+  "content" : [ {
+    "title" : "title",
+    "dataTypes" : [ "주제 1" ],
+    "topics" : [ "데이터 유형 1" ],
+    "developmentYears" : [ 2024 ],
+    "professor" : "담당 교수 1",
+    "participants" : [ "학생 1", "학생 2" ],
+    "object" : "page",
+    "id" : "노션 object 아이디",
+    "cover" : "커버 이미지 link",
+    "url" : "노션 redirect url"
+  }, {
+    "title" : "title",
+    "dataTypes" : [ "주제 1", "주제 2" ],
+    "topics" : [ "데이터 유형 1" ],
+    "developmentYears" : [ 2024 ],
+    "professor" : "담당 교수 1",
+    "participants" : [ "학생 1", "학생 2", "학생 3" ],
+    "object" : "page",
+    "id" : "노션 object 아이디",
+    "cover" : "커버 이미지 link",
+    "url" : "노션 redirect url"
+  } ],
+  "number" : 0,
+  "sort" : {
+    "empty" : true,
+    "sorted" : false,
+    "unsorted" : true
+  },
+  "numberOfElements" : 2,
+  "pageable" : {
+    "pageNumber" : 0,
+    "pageSize" : 10,
+    "sort" : {
+      "empty" : true,
+      "sorted" : false,
+      "unsorted" : true
+    },
+    "offset" : 0,
+    "paged" : true,
+    "unpaged" : false
+  },
+  "empty" : false
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

content[].object

String

true

object 종류

content[].id

String

true

노션 object 아이디

content[].cover

String

true

커버 이미지 link

content[].title

String

true

AI 데이터셋 제목

content[].topics

Array

true

주제 분류

content[].dataTypes

Array

true

데이터 유형

content[].developmentYears

Array

true

구축 년도

content[].professor

String

true

담당 교수

content[].participants

Array

true

참여 학생

content[].url

String

true

노션 redirect url

pageable

Object

true

페이지 정보

pageable.pageNumber

Number

true

현재 페이지 번호

pageable.pageSize

Number

true

페이지 당 요소 수

pageable.sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

pageable.sort.sorted

Boolean

true

정렬된 상태인지 여부

pageable.sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

pageable.offset

Number

true

오프셋

pageable.paged

Boolean

true

페이징된 여부

pageable.unpaged

Boolean

true

페이징되지 않은 여부

totalElements

Number

true

전체 요소 수

totalPages

Number

true

전체 페이지 수

size

Number

true

페이지 당 요소 수

number

Number

true

현재 페이지 번호

numberOfElements

Number

true

현재 페이지 요소 수

first

Boolean

true

첫 페이지 여부

last

Boolean

true

마지막 페이지 여부

sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

sort.sorted

Boolean

true

정렬된 상태인지 여부

empty

Boolean

true

비어있는 페이지 여부

+
+
+
+
+
+
+
+

JOB INFO API

+
+
+
+

JOB INFO 리스트 조회 (POST /jobInfos)

+
+
+
+

Query parameters

+ +++++ + + + + + + + + + + + + + + + + + + + +
ParameterRequiredDescription

page

false

페이지 번호 [default: 0]

size

false

페이지 크기 [default: 10]

+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

company

String

true

기업명

jobTypes

Array

true

고용 형태

region

String

true

근무 지역

position

String

true

채용 포지션

hiringTime

String

true

채용 시점

state

Array

true

채용 상태

+
+
+

HTTP request

+
+
+
POST /jobInfos HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Content-Length: 198
+Host: localhost:8080
+
+{
+  "company" : "기업명 1",
+  "jobTypes" : [ "고용 형태 1" ],
+  "region" : "근무 지역 1",
+  "position" : "채용 포지션 1",
+  "hiringTime" : "채용 시점 1",
+  "state" : [ "open" ]
+}
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 1295
+
+{
+  "totalPages" : 1,
+  "totalElements" : 2,
+  "first" : true,
+  "last" : true,
+  "size" : 10,
+  "content" : [ {
+    "company" : "기업명 1",
+    "jobTypes" : [ "고용 형태 1" ],
+    "region" : "근무 지역 1",
+    "position" : "채용 포지션 1",
+    "logo" : "로고 url",
+    "salary" : "연봉",
+    "website" : "웹사이트 url",
+    "state" : [ "open" ],
+    "hiringTime" : "채용 시점 1",
+    "object" : "page",
+    "id" : "노션 object 아이디 1",
+    "url" : "redirect url"
+  }, {
+    "company" : "기업명 1",
+    "jobTypes" : [ "고용 형태 1", "고용 형태 2" ],
+    "region" : "근무 지역 2",
+    "position" : "채용 포지션 1, 채용 시점 2",
+    "logo" : "로고 url",
+    "salary" : "연봉",
+    "website" : "웹사이트 url",
+    "state" : [ "open" ],
+    "hiringTime" : "채용 시점 1",
+    "object" : "page",
+    "id" : "노션 object 아이디 2",
+    "url" : "redirect url"
+  } ],
+  "number" : 0,
+  "sort" : {
+    "empty" : true,
+    "sorted" : false,
+    "unsorted" : true
+  },
+  "numberOfElements" : 2,
+  "pageable" : {
+    "pageNumber" : 0,
+    "pageSize" : 10,
+    "sort" : {
+      "empty" : true,
+      "sorted" : false,
+      "unsorted" : true
+    },
+    "offset" : 0,
+    "paged" : true,
+    "unpaged" : false
+  },
+  "empty" : false
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

content[].object

String

true

object 종류

content[].id

String

true

노션 object 아이디

content[].company

String

true

기업명

content[].jobTypes

Array

true

고용 형태

content[].region

String

true

근무 지역

content[].position

String

true

채용 포지션

content[].logo

String

true

로고 url

content[].salary

String

true

연봉

content[].website

String

true

웹사이트 url

content[].state

Array

true

채용 상태

content[].hiringTime

String

true

채용 시점

content[].url

String

true

redirect url

pageable

Object

true

페이지 정보

pageable.pageNumber

Number

true

현재 페이지 번호

pageable.pageSize

Number

true

페이지 당 요소 수

pageable.sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

pageable.sort.sorted

Boolean

true

정렬된 상태인지 여부

pageable.sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

pageable.offset

Number

true

오프셋

pageable.paged

Boolean

true

페이징된 여부

pageable.unpaged

Boolean

true

페이징되지 않은 여부

totalElements

Number

true

전체 요소 수

totalPages

Number

true

전체 페이지 수

size

Number

true

페이지 당 요소 수

number

Number

true

현재 페이지 번호

numberOfElements

Number

true

현재 페이지 요소 수

first

Boolean

true

첫 페이지 여부

last

Boolean

true

마지막 페이지 여부

sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

sort.sorted

Boolean

true

정렬된 상태인지 여부

empty

Boolean

true

비어있는 페이지 여부

+
+
+
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/src/main/resources/static/docs/inquiry.html b/src/main/resources/static/docs/inquiry.html new file mode 100644 index 00000000..3b9c5ee9 --- /dev/null +++ b/src/main/resources/static/docs/inquiry.html @@ -0,0 +1,1692 @@ + + + + + + + +프로젝트 문의 사항 API + + + + + +
+
+

프로젝트 문의 사항 API

+
+
+
+

프로젝트 문의 사항 생성 (POST /projects/{projectId}/inquiry)

+
+
+
+

HTTP request

+
+
+
POST /projects/1/inquiry HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: company_access_token
+Content-Length: 102
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "title" : "프로젝트 문의 사항 제목",
+  "content" : "프로젝트 문의 사항 내용"
+}
+
+
+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

문의 사항 제목

content

String

true

문의 사항 내용

+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 305
+
+{
+  "id" : 1,
+  "authorName" : "문의 작성자 이름",
+  "projectId" : 1,
+  "projectName" : "프로젝트 이름",
+  "title" : "문의 사항 제목",
+  "content" : "문의 사항 내용",
+  "replied" : false,
+  "createdAt" : "2024-11-28T19:49:20.275734",
+  "updatedAt" : "2024-11-28T19:49:20.275739"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

문의 사항 ID

authorName

String

true

문의 작성자 이름

projectId

Number

true

문의 대상 프로젝트 ID

projectName

String

true

문의 대상 프로젝트 이름

title

String

true

문의 사항 제목

content

String

true

문의 사항 내용

replied

Boolean

true

답변 등록 여부

createdAt

String

true

문의 사항 생성 시간

updatedAt

String

true

문의 사항 수정 시간

+
+
+
+
+
+
+

프로젝트 문의 사항 리스트 조회 (GET /inquiries)

+
+
+
+

HTTP request

+
+
+
GET /inquiries?terms=inquiry&scope=title&page=0&size=10 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: company_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Query parameters

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterRequiredDescription

terms

false

검색어

scope

false

검색 범위 (title, content, both, author) [default: both, searchTerm=null 이면 null로 초기화]

page

false

페이지 번호 [default: 0]

size

false

페이지 크기 [default: 10]

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 833
+
+{
+  "totalPages" : 1,
+  "totalElements" : 2,
+  "first" : true,
+  "last" : true,
+  "size" : 10,
+  "content" : [ {
+    "id" : 1,
+    "authorName" : "프로젝트 inquiry (문의) 제목 1",
+    "title" : "프로젝트 문의 사항 내용",
+    "createdAt" : "2024-11-28T19:49:20.236706"
+  }, {
+    "id" : 2,
+    "authorName" : "프로젝트 inquiry (문의) 제목 2",
+    "title" : "프로젝트 문의 사항 내용",
+    "createdAt" : "2024-11-28T19:49:20.236735"
+  } ],
+  "number" : 0,
+  "sort" : {
+    "empty" : true,
+    "sorted" : false,
+    "unsorted" : true
+  },
+  "numberOfElements" : 2,
+  "pageable" : {
+    "pageNumber" : 0,
+    "pageSize" : 10,
+    "sort" : {
+      "empty" : true,
+      "sorted" : false,
+      "unsorted" : true
+    },
+    "offset" : 0,
+    "paged" : true,
+    "unpaged" : false
+  },
+  "empty" : false
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

content[].id

Number

true

문의 사항 ID

content[].authorName

String

true

문의 작성자 이름

content[].title

String

true

문의 사항 제목

content[].createdAt

String

true

문의 사항 생성 시간

pageable.pageNumber

Number

true

현재 페이지 번호

pageable.pageSize

Number

true

페이지 당 요소 수

pageable.sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

pageable.sort.sorted

Boolean

true

정렬된 상태인지 여부

pageable.sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

pageable.offset

Number

true

오프셋

pageable.paged

Boolean

true

페이징된 여부

pageable.unpaged

Boolean

true

페이징되지 않은 여부

totalElements

Number

true

전체 요소 수

totalPages

Number

true

전체 페이지 수

size

Number

true

페이지 당 요소 수

number

Number

true

현재 페이지 번호

numberOfElements

Number

true

현재 페이지 요소 수

first

Boolean

true

첫 페이지 여부

last

Boolean

true

마지막 페이지 여부

sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

sort.sorted

Boolean

true

정렬된 상태인지 여부

empty

Boolean

true

비어있는 페이지 여부

+
+
+
+
+
+
+

프로젝트 문의 사항 단건 조회 (GET /inquiries/{inquiryId})

+
+
+
+

HTTP request

+
+
+
GET /inquiries/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: company_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /inquiries/{inquiryId}
ParameterDescription

inquiryId

조회할 문의 사항 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 305
+
+{
+  "id" : 1,
+  "authorName" : "문의 작성자 이름",
+  "projectId" : 1,
+  "projectName" : "프로젝트 이름",
+  "title" : "문의 사항 제목",
+  "content" : "문의 사항 내용",
+  "replied" : false,
+  "createdAt" : "2024-11-28T19:49:20.260492",
+  "updatedAt" : "2024-11-28T19:49:20.260496"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

문의 사항 ID

authorName

String

true

문의 작성자 이름

projectId

Number

true

문의 대상 프로젝트 ID

projectName

String

true

문의 대상 프로젝트 이름

title

String

true

문의 사항 제목

content

String

true

문의 사항 내용

replied

Boolean

true

답변 등록 여부

createdAt

String

true

문의 사항 생성 시간

updatedAt

String

true

문의 사항 수정 시간

+
+
+
+
+
+
+

프로젝트 문의 사항 수정 (PUT /inquiries/{inquiryId})

+
+
+
+

HTTP request

+
+
+
PUT /inquiries/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: company_access_token
+Content-Length: 122
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "title" : "수정된 프로젝트 문의 사항 제목",
+  "content" : "수정된 프로젝트 문의 사항 내용"
+}
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /inquiries/{inquiryId}
ParameterDescription

inquiryId

수정할 문의 사항 ID

+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

문의 사항 제목

content

String

true

문의 사항 내용

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 325
+
+{
+  "id" : 1,
+  "authorName" : "문의 작성자 이름",
+  "projectId" : 1,
+  "projectName" : "프로젝트 이름",
+  "title" : "수정된 문의 사항 제목",
+  "content" : "수정된 문의 사항 내용",
+  "replied" : false,
+  "createdAt" : "2024-11-28T19:49:20.296233",
+  "updatedAt" : "2024-11-28T19:49:20.296237"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

문의 사항 ID

authorName

String

true

문의 작성자 이름

projectId

Number

true

문의 대상 프로젝트 ID

projectName

String

true

문의 대상 프로젝트 이름

title

String

true

수정된 문의 사항 제목

content

String

true

수정된 문의 사항 내용

replied

Boolean

true

답변 등록 여부

createdAt

String

true

문의 사항 생성 시간

updatedAt

String

true

문의 사항 수정 시간

+
+
+
+
+
+
+

프로젝트 문의 사항 삭제 (DELETE /inquiries/{inquiryId})

+
+
+
+

HTTP request

+
+
+
DELETE /inquiries/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: company_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /inquiries/{inquiryId}
ParameterDescription

inquiryId

삭제할 문의 사항 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+
+
+

프로젝트 문의 사항 답변 (POST /inquiries/{inquiryId}/reply)

+
+
+
+

HTTP request

+
+
+
POST /inquiries/1/reply HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Content-Length: 76
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "title" : "문의 답변 제목",
+  "content" : "문의 답변 내용"
+}
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /inquiries/{inquiryId}/reply
ParameterDescription

inquiryId

답변할 문의 ID

+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

답변 제목

content

String

true

답변 내용

+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 88
+
+{
+  "id" : 1,
+  "title" : "문의 답변 제목",
+  "content" : "문의 답변 내용"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

답변 ID

title

String

true

답변 제목

content

String

true

답변 내용

+
+
+
+
+
+
+

프로젝트 문의 사항 답변 조회 (GET /inquiries/{inquiryId}/reply)

+
+
+
+

HTTP request

+
+
+
GET /inquiries/1/reply HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: company_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /inquiries/{inquiryId}/reply
ParameterDescription

inquiryId

조회할 문의 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 88
+
+{
+  "id" : 1,
+  "title" : "문의 답변 제목",
+  "content" : "문의 답변 내용"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

답변 ID

title

String

true

답변 제목

content

String

true

답변 내용

+
+
+
+
+
+
+

프로젝트 문의 사항 답변 수정 (PUT /inquiries/{inquiryId}/reply)

+
+
+
+

HTTP request

+
+
+
PUT /inquiries/1/reply HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Content-Length: 96
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "title" : "수정된 문의 답변 제목",
+  "content" : "수정된 문의 답변 내용"
+}
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /inquiries/{inquiryId}/reply
ParameterDescription

inquiryId

수정할 답변 ID

+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

답변 제목

content

String

true

답변 내용

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 108
+
+{
+  "id" : 1,
+  "title" : "수정된 문의 답변 제목",
+  "content" : "수정된 문의 답변 내용"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

답변 ID

title

String

true

수정된 답변 제목

content

String

true

수정된 답변 내용

+
+
+
+
+
+
+

프로젝트 문의 사항 답변 삭제 (DELETE /inquiries/{inquiryId}/reply)

+
+
+
+

HTTP request

+
+
+
DELETE /inquiries/1/reply HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /inquiries/{inquiryId}/reply
ParameterDescription

inquiryId

삭제할 답변 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/jobInfo-controller-test.html b/src/main/resources/static/docs/jobInfo-controller-test.html new file mode 100644 index 00000000..fa4c4c30 --- /dev/null +++ b/src/main/resources/static/docs/jobInfo-controller-test.html @@ -0,0 +1,851 @@ + + + + + + + +JOB INFO API + + + + + +
+
+

JOB INFO API

+
+
+
+

JOB INFO 리스트 조회 (POST /jobInfos)

+
+
+
+

Query parameters

+ +++++ + + + + + + + + + + + + + + + + + + + +
ParameterRequiredDescription

page

false

페이지 번호 [default: 0]

size

false

페이지 크기 [default: 10]

+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

company

String

true

기업명

jobTypes

Array

true

고용 형태

region

String

true

근무 지역

position

String

true

채용 포지션

hiringTime

String

true

채용 시점

state

Array

true

채용 상태

+
+
+

HTTP request

+
+
+
POST /jobInfos HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Content-Length: 198
+Host: localhost:8080
+
+{
+  "company" : "기업명 1",
+  "jobTypes" : [ "고용 형태 1" ],
+  "region" : "근무 지역 1",
+  "position" : "채용 포지션 1",
+  "hiringTime" : "채용 시점 1",
+  "state" : [ "open" ]
+}
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 1295
+
+{
+  "totalPages" : 1,
+  "totalElements" : 2,
+  "first" : true,
+  "last" : true,
+  "size" : 10,
+  "content" : [ {
+    "company" : "기업명 1",
+    "jobTypes" : [ "고용 형태 1" ],
+    "region" : "근무 지역 1",
+    "position" : "채용 포지션 1",
+    "logo" : "로고 url",
+    "salary" : "연봉",
+    "website" : "웹사이트 url",
+    "state" : [ "open" ],
+    "hiringTime" : "채용 시점 1",
+    "object" : "page",
+    "id" : "노션 object 아이디 1",
+    "url" : "redirect url"
+  }, {
+    "company" : "기업명 1",
+    "jobTypes" : [ "고용 형태 1", "고용 형태 2" ],
+    "region" : "근무 지역 2",
+    "position" : "채용 포지션 1, 채용 시점 2",
+    "logo" : "로고 url",
+    "salary" : "연봉",
+    "website" : "웹사이트 url",
+    "state" : [ "open" ],
+    "hiringTime" : "채용 시점 1",
+    "object" : "page",
+    "id" : "노션 object 아이디 2",
+    "url" : "redirect url"
+  } ],
+  "number" : 0,
+  "sort" : {
+    "empty" : true,
+    "sorted" : false,
+    "unsorted" : true
+  },
+  "numberOfElements" : 2,
+  "pageable" : {
+    "pageNumber" : 0,
+    "pageSize" : 10,
+    "sort" : {
+      "empty" : true,
+      "sorted" : false,
+      "unsorted" : true
+    },
+    "offset" : 0,
+    "paged" : true,
+    "unpaged" : false
+  },
+  "empty" : false
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

content[].object

String

true

object 종류

content[].id

String

true

노션 object 아이디

content[].company

String

true

기업명

content[].jobTypes

Array

true

고용 형태

content[].region

String

true

근무 지역

content[].position

String

true

채용 포지션

content[].logo

String

true

로고 url

content[].salary

String

true

연봉

content[].website

String

true

웹사이트 url

content[].state

Array

true

채용 상태

content[].hiringTime

String

true

채용 시점

content[].url

String

true

redirect url

pageable

Object

true

페이지 정보

pageable.pageNumber

Number

true

현재 페이지 번호

pageable.pageSize

Number

true

페이지 당 요소 수

pageable.sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

pageable.sort.sorted

Boolean

true

정렬된 상태인지 여부

pageable.sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

pageable.offset

Number

true

오프셋

pageable.paged

Boolean

true

페이징된 여부

pageable.unpaged

Boolean

true

페이징되지 않은 여부

totalElements

Number

true

전체 요소 수

totalPages

Number

true

전체 페이지 수

size

Number

true

페이지 당 요소 수

number

Number

true

현재 페이지 번호

numberOfElements

Number

true

현재 페이지 요소 수

first

Boolean

true

첫 페이지 여부

last

Boolean

true

마지막 페이지 여부

sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

sort.sorted

Boolean

true

정렬된 상태인지 여부

empty

Boolean

true

비어있는 페이지 여부

+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/jobInterview.html b/src/main/resources/static/docs/jobInterview.html new file mode 100644 index 00000000..ea2bd93b --- /dev/null +++ b/src/main/resources/static/docs/jobInterview.html @@ -0,0 +1,1483 @@ + + + + + + + +잡페어 인터뷰 API + + + + + +
+
+

잡페어 인터뷰 API

+
+
+
+

잡페어 인터뷰 생성 (POST /jobInterviews)

+
+
+
+

HTTP request

+
+
+
POST /jobInterviews HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Content-Length: 213
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "title" : "잡페어 인터뷰의 제목",
+  "youtubeId" : "유튜브 고유 ID",
+  "year" : 2024,
+  "talkerBelonging" : "대담자의 소속",
+  "talkerName" : "대담자의 성명",
+  "category" : "INTERN"
+}
+
+
+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

잡페어 인터뷰 제목

youtubeId

String

true

유튜브 영상의 고유 ID

year

Number

true

잡페어 인터뷰 연도

talkerBelonging

String

true

잡페어 인터뷰 대담자의 소속

talkerName

String

true

잡페어 인터뷰 대담자의 성명

category

String

true

잡페어 인터뷰 카테고리: SENIOR, INTERN

+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 317
+
+{
+  "id" : 1,
+  "title" : "잡페어 인터뷰의 제목",
+  "youtubeId" : "유튜브 고유 ID",
+  "year" : 2024,
+  "talkerBelonging" : "대담자의 소속",
+  "talkerName" : "대담자의 성명",
+  "category" : "INTERN",
+  "createdAt" : "2024-11-28T19:49:22.247595",
+  "updatedAt" : "2024-11-28T19:49:22.247596"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

잡페어 인터뷰 ID

title

String

true

잡페어 인터뷰 제목

youtubeId

String

true

유튜브 영상의 고유 ID

year

Number

true

잡페어 인터뷰 연도

talkerBelonging

String

true

잡페어 인터뷰 대담자의 소속

talkerName

String

true

잡페어 인터뷰 대담자의 성명

category

String

true

잡페어 인터뷰 카테고리: SENIOR, INTERN

createdAt

String

true

잡페어 인터뷰 생성일

updatedAt

String

true

잡페어 인터뷰 수정일

+
+
+
+
+
+

잡페어 인터뷰 리스트 조회 (GET /jobInterviews)

+
+
+
+

HTTP request

+
+
+
GET /jobInterviews HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: optional_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Query parameters

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterRequiredDescription

year

false

찾고자 하는 잡페어 인터뷰 연도

category

false

찾고자 하는 잡페어 인터뷰 카테고리: SENIOR, INTERN

title

false

찾고자 하는 잡페어 인터뷰의 제목

page

false

페이지 번호 [default: 0]

size

false

페이지 크기 [default: 10]

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 1206
+
+{
+  "totalPages" : 1,
+  "totalElements" : 2,
+  "first" : true,
+  "last" : true,
+  "size" : 10,
+  "content" : [ {
+    "id" : 1,
+    "title" : "잡페어 인터뷰의 제목1",
+    "youtubeId" : "유튜브 고유 ID1",
+    "year" : 2023,
+    "talkerBelonging" : "대담자의 소속1",
+    "talkerName" : "대담자의 성명1",
+    "favorite" : false,
+    "category" : "INTERN",
+    "createdAt" : "2024-11-28T19:49:22.228935",
+    "updatedAt" : "2024-11-28T19:49:22.228936"
+  }, {
+    "id" : 2,
+    "title" : "잡페어 인터뷰의 제목2",
+    "youtubeId" : "유튜브 고유 ID2",
+    "year" : 2024,
+    "talkerBelonging" : "대담자의 소속2",
+    "talkerName" : "대담자의 성명2",
+    "favorite" : true,
+    "category" : "INTERN",
+    "createdAt" : "2024-11-28T19:49:22.228941",
+    "updatedAt" : "2024-11-28T19:49:22.228941"
+  } ],
+  "number" : 0,
+  "sort" : {
+    "empty" : true,
+    "sorted" : false,
+    "unsorted" : true
+  },
+  "numberOfElements" : 2,
+  "pageable" : {
+    "pageNumber" : 0,
+    "pageSize" : 10,
+    "sort" : {
+      "empty" : true,
+      "sorted" : false,
+      "unsorted" : true
+    },
+    "offset" : 0,
+    "paged" : true,
+    "unpaged" : false
+  },
+  "empty" : false
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

pageable

Object

true

페이지 정보

pageable.pageNumber

Number

true

현재 페이지 번호

pageable.pageSize

Number

true

페이지 당 요소 수

pageable.sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

pageable.sort.sorted

Boolean

true

정렬된 상태인지 여부

pageable.sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

pageable.offset

Number

true

오프셋

pageable.paged

Boolean

true

페이징된 여부

pageable.unpaged

Boolean

true

페이징되지 않은 여부

totalElements

Number

true

전체 요소 수

totalPages

Number

true

전체 페이지 수

size

Number

true

페이지 당 요소 수

number

Number

true

현재 페이지 번호

numberOfElements

Number

true

현재 페이지 요소 수

first

Boolean

true

첫 페이지 여부

last

Boolean

true

마지막 페이지 여부

sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

sort.sorted

Boolean

true

정렬된 상태인지 여부

empty

Boolean

true

비어있는 페이지 여부

content[].id

Number

true

잡페어 인터뷰 ID

content[].title

String

true

잡페어 인터뷰 제목

content[].youtubeId

String

true

유튜브 영상의 고유 ID

content[].year

Number

true

잡페어 인터뷰 연도

content[].talkerBelonging

String

true

잡페어 인터뷰 대담자의 소속

content[].talkerName

String

true

잡페어 인터뷰 대담자의 성명

content[].favorite

Boolean

true

관심에 추가한 잡페어 인터뷰 여부

content[].category

String

true

잡페어 인터뷰 카테고리: SENIOR, INTERN

content[].createdAt

String

true

잡페어 인터뷰 생성일

content[].updatedAt

String

true

잡페어 인터뷰 수정일

+
+
+
+
+
+

잡페어 인터뷰 단건 조회 (GET /jobInterviews/{jobInterviewId})

+
+
+
+

HTTP request

+
+
+
GET /jobInterviews/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: optional_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /jobInterviews/{jobInterviewId}
ParameterDescription

jobInterviewId

조회할 잡페어 인터뷰의 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 339
+
+{
+  "id" : 1,
+  "title" : "잡페어 인터뷰의 제목",
+  "youtubeId" : "유튜브 고유 ID",
+  "year" : 2024,
+  "talkerBelonging" : "대담자의 소속",
+  "talkerName" : "대담자의 성명",
+  "favorite" : false,
+  "category" : "INTERN",
+  "createdAt" : "2024-11-28T19:49:22.240617",
+  "updatedAt" : "2024-11-28T19:49:22.240618"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

잡페어 인터뷰 ID

title

String

true

잡페어 인터뷰 제목

youtubeId

String

true

유튜브 영상의 고유 ID

year

Number

true

잡페어 인터뷰 연도

talkerBelonging

String

true

잡페어 인터뷰 대담자의 소속

talkerName

String

true

잡페어 인터뷰 대담자의 성명

favorite

Boolean

true

관심에 추가한 잡페어 인터뷰 여부

category

String

true

잡페어 인터뷰 카테고리: SENIOR, INTERN

createdAt

String

true

잡페어 인터뷰 생성일

updatedAt

String

true

잡페어 인터뷰 수정일

+
+
+
+
+
+

잡페어 인터뷰 수정 (PUT /jobInterviews/{jobInterviewId})

+
+
+
+

HTTP request

+
+
+
PUT /jobInterviews/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Content-Length: 217
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "title" : "수정된 제목",
+  "youtubeId" : "수정된 유튜브 ID",
+  "year" : 2024,
+  "talkerBelonging" : "수정된 대담자 소속",
+  "talkerName" : "수정된 대담자 성명",
+  "category" : "INTERN"
+}
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /jobInterviews/{jobInterviewId}
ParameterDescription

jobInterviewId

수정할 잡페어 인터뷰의 ID

+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

잡페어 인터뷰 제목

youtubeId

String

true

유튜브 영상의 고유 ID

year

Number

true

잡페어 인터뷰 연도

talkerBelonging

String

true

잡페어 인터뷰 대담자의 소속

talkerName

String

true

잡페어 인터뷰 대담자의 성명

category

String

true

잡페어 인터뷰 카테고리: SENIOR, INTERN

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 314
+
+{
+  "id" : 1,
+  "title" : "수정된 제목",
+  "youtubeId" : "수정된 유튜브 ID",
+  "year" : 2024,
+  "talkerBelonging" : "수정된 대담자 소속",
+  "talkerName" : "수정된 대담자 성명",
+  "category" : "INTERN",
+  "createdAt" : "2021-01-01T12:00:00",
+  "updatedAt" : "2024-11-28T19:49:22.208502"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

잡페어 인터뷰 ID

title

String

true

잡페어 인터뷰 제목

youtubeId

String

true

유튜브 영상의 고유 ID

year

Number

true

잡페어 인터뷰 연도

talkerBelonging

String

true

잡페어 인터뷰 대담자의 소속

talkerName

String

true

잡페어 인터뷰 대담자의 성명

category

String

true

잡페어 인터뷰 카테고리: SENIOR, INTERN

createdAt

String

true

잡페어 인터뷰 생성일

updatedAt

String

true

잡페어 인터뷰 수정일

+
+
+
+
+
+

잡페어 인터뷰 삭제 (DELETE /jobInterviews/{jobInterviewId})

+
+
+
+

HTTP request

+
+
+
DELETE /jobInterviews/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /jobInterviews/{jobInterviewId}
ParameterDescription

jobInterviewId

삭제할 잡페어 인터뷰의 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+
+

잡페어 인터뷰 관심 등록 (POST /jobInterviews/{jobInterviews}/favorite)

+
+
+
+

HTTP request

+
+
+
POST /jobInterviews/1/favorite HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /jobInterviews/{jobInterviewId}/favorite
ParameterDescription

jobInterviewId

관심 목록에 추가할 잡페어 인터뷰의 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+
+

잡페어 인터뷰 관심 삭제 (DELETE /jobInterviews/{jobInterviews}/favorite)

+
+
+
+

HTTP request

+
+
+
DELETE /jobInterviews/1/favorite HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /jobInterviews/{jobInterviewId}/favorite
ParameterDescription

jobInterviewId

관심 목록에서 삭제할 잡페어 인터뷰의 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/notice.html b/src/main/resources/static/docs/notice.html new file mode 100644 index 00000000..16379ed0 --- /dev/null +++ b/src/main/resources/static/docs/notice.html @@ -0,0 +1,1452 @@ + + + + + + + +공지 사항 API + + + + + +
+
+

공지 사항 API

+
+
+

공지 사항 생성 (POST /notices)

+
+
+
+

HTTP request

+
+
+
POST /notices HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Content-Length: 121
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "title" : "공지 사항 제목",
+  "content" : "공지 사항 내용",
+  "fixed" : true,
+  "fileIds" : [ 1, 2, 3 ]
+}
+
+
+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

공지 사항 제목

content

String

true

공지 사항 내용

fixed

Boolean

true

공지 사항 고정 여부

fileIds

Array

false

첨부 파일 ID 목록

+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 960
+
+{
+  "id" : 1,
+  "title" : "공지 사항 제목",
+  "content" : "공지 사항 내용",
+  "hitCount" : 0,
+  "fixed" : true,
+  "createdAt" : "2024-11-28T19:49:20.765811",
+  "updatedAt" : "2024-11-28T19:49:20.765812",
+  "files" : [ {
+    "id" : 1,
+    "uuid" : "014eb8a0-d4a6-11ee-adac-117d766aca1d",
+    "name" : "예시 첨부 파일 1.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:20.765803",
+    "updatedAt" : "2024-11-28T19:49:20.765806"
+  }, {
+    "id" : 2,
+    "uuid" : "11a480c0-13fa-11ef-9047-570191b390ea",
+    "name" : "예시 첨부 파일 2.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:20.765807",
+    "updatedAt" : "2024-11-28T19:49:20.765808"
+  }, {
+    "id" : 3,
+    "uuid" : "1883fc70-cfb4-11ee-a387-e754bd392d45",
+    "name" : "예시 첨부 파일 3.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:20.765809",
+    "updatedAt" : "2024-11-28T19:49:20.76581"
+  } ]
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

공지 사항 ID

title

String

true

공지 사항 제목

content

String

true

공지 사항 내용

hitCount

Number

true

공지 사항 조회수

fixed

Boolean

true

공지 사항 고정 여부

createdAt

String

true

공지 사항 생성일

updatedAt

String

true

공지 사항 수정일

files

Array

true

첨부 파일 목록

files[].id

Number

true

파일 ID

files[].uuid

String

true

파일 고유 식별자

files[].name

String

true

파일 이름

files[].mimeType

String

true

파일 MIME 타입

files[].createdAt

String

true

파일 생성 시간

files[].updatedAt

String

true

파일 수정 시간

+
+
+
+
+
+

공지 사항 리스트 조회 (GET /notices)

+
+
+
+

Query parameters

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterRequiredDescription

terms

false

검색어 (optional)

scope

false

검색 범위 (title, content, both) [default: both, searchTerm=null 이면 null로 초기화]

page

false

페이지 번호 [default: 0]

size

false

페이지 크기 [default: 10]

+
+
+

HTTP request

+
+
+
GET /notices?terms=notice&scope=title&page=0&size=10 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Host: localhost:8080
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 838
+
+{
+  "totalPages" : 1,
+  "totalElements" : 2,
+  "first" : true,
+  "last" : true,
+  "size" : 10,
+  "content" : [ {
+    "id" : 1,
+    "title" : "notice 1",
+    "hitCount" : 10,
+    "fixed" : true,
+    "createdAt" : "2024-11-28T19:49:20.7253",
+    "updatedAt" : "2024-11-28T19:49:20.725302"
+  }, {
+    "id" : 2,
+    "title" : "notice 2",
+    "hitCount" : 10,
+    "fixed" : false,
+    "createdAt" : "2024-11-28T19:49:20.725317",
+    "updatedAt" : "2024-11-28T19:49:20.725318"
+  } ],
+  "number" : 0,
+  "sort" : {
+    "empty" : true,
+    "sorted" : false,
+    "unsorted" : true
+  },
+  "numberOfElements" : 2,
+  "pageable" : {
+    "pageNumber" : 0,
+    "pageSize" : 10,
+    "sort" : {
+      "empty" : true,
+      "sorted" : false,
+      "unsorted" : true
+    },
+    "offset" : 0,
+    "paged" : true,
+    "unpaged" : false
+  },
+  "empty" : false
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

content[].id

Number

true

공지 사항 ID

content[].title

String

true

공지 사항 제목

content[].hitCount

Number

true

공지 사항 조회수

content[].fixed

Boolean

true

공지 사항 고정 여부

content[].createdAt

String

true

공지 사항 생성일

content[].updatedAt

String

true

공지 사항 수정일

pageable

Object

true

페이지 정보

pageable.pageNumber

Number

true

현재 페이지 번호

pageable.pageSize

Number

true

페이지 당 요소 수

pageable.sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

pageable.sort.sorted

Boolean

true

정렬된 상태인지 여부

pageable.sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

pageable.offset

Number

true

오프셋

pageable.paged

Boolean

true

페이징된 여부

pageable.unpaged

Boolean

true

페이징되지 않은 여부

totalElements

Number

true

전체 요소 수

totalPages

Number

true

전체 페이지 수

size

Number

true

페이지 당 요소 수

number

Number

true

현재 페이지 번호

numberOfElements

Number

true

현재 페이지 요소 수

first

Boolean

true

첫 페이지 여부

last

Boolean

true

마지막 페이지 여부

sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

sort.sorted

Boolean

true

정렬된 상태인지 여부

empty

Boolean

true

비어있는 페이지 여부

+
+
+
+
+
+

공지 사항 조회 (GET /notices/{noticeId})

+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /notices/{noticeId}
ParameterDescription

noticeId

조회할 공지 사항 ID

+
+
+

HTTP request

+
+
+
GET /notices/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Host: localhost:8080
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 949
+
+{
+  "id" : 1,
+  "title" : "공지 사항 제목",
+  "content" : "content",
+  "hitCount" : 10,
+  "fixed" : true,
+  "createdAt" : "2024-11-28T19:49:20.757948",
+  "updatedAt" : "2024-11-28T19:49:20.757949",
+  "files" : [ {
+    "id" : 1,
+    "uuid" : "014eb8a0-d4a6-11ee-adac-117d766aca1d",
+    "name" : "예시 첨부 파일 1.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:20.757939",
+    "updatedAt" : "2024-11-28T19:49:20.757942"
+  }, {
+    "id" : 2,
+    "uuid" : "11a480c0-13fa-11ef-9047-570191b390ea",
+    "name" : "예시 첨부 파일 2.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:20.757943",
+    "updatedAt" : "2024-11-28T19:49:20.757944"
+  }, {
+    "id" : 3,
+    "uuid" : "1883fc70-cfb4-11ee-a387-e754bd392d45",
+    "name" : "예시 첨부 파일 3.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-11-28T19:49:20.757945",
+    "updatedAt" : "2024-11-28T19:49:20.757946"
+  } ]
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

공지 사항 ID

title

String

true

공지 사항 제목

content

String

true

공지 사항 내용

hitCount

Number

true

공지 사항 조회수

fixed

Boolean

true

공지 사항 고정 여부

createdAt

String

true

공지 사항 생성일

updatedAt

String

true

공지 사항 수정일

files

Array

true

첨부 파일 목록

files[].id

Number

true

파일 ID

files[].uuid

String

true

파일 고유 식별자

files[].name

String

true

파일 이름

files[].mimeType

String

true

파일 MIME 타입

files[].createdAt

String

true

파일 생성 시간

files[].updatedAt

String

true

파일 수정 시간

+
+
+
+
+
+

공지 사항 수정 (PUT /notices/{noticeId})

+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /notices/{noticeId}
ParameterDescription

noticeId

수정할 공지 사항 ID

+
+
+

HTTP request

+
+
+
PUT /notices/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Content-Length: 142
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "title" : "수정된 공지 사항 제목",
+  "content" : "수정된 공지 사항 내용",
+  "fixed" : false,
+  "fileIds" : [ 1, 2, 3 ]
+}
+
+
+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

공지 사항 제목

content

String

true

공지 사항 내용

fixed

Boolean

true

공지 사항 고정 여부

fileIds

Array

false

첨부 파일 ID 목록

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 955
+
+{
+  "id" : 1,
+  "title" : "수정된 공지 사항 제목",
+  "content" : "수정된 공지 사항 내용",
+  "hitCount" : 10,
+  "fixed" : false,
+  "createdAt" : "2024-01-01T12:00:00",
+  "updatedAt" : "2024-11-28T19:49:20.739212",
+  "files" : [ {
+    "id" : 1,
+    "uuid" : "014eb8a0-d4a6-11ee-adac-117d766aca1d",
+    "name" : "예시 첨부 파일 1.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-01-01T12:00:00",
+    "updatedAt" : "2024-11-28T19:49:20.739182"
+  }, {
+    "id" : 2,
+    "uuid" : "11a480c0-13fa-11ef-9047-570191b390ea",
+    "name" : "예시 첨부 파일 2.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-01-01T12:00:00",
+    "updatedAt" : "2024-11-28T19:49:20.739187"
+  }, {
+    "id" : 3,
+    "uuid" : "1883fc70-cfb4-11ee-a387-e754bd392d45",
+    "name" : "예시 첨부 파일 3.jpg",
+    "mimeType" : "image/jpeg",
+    "createdAt" : "2024-01-01T12:00:00",
+    "updatedAt" : "2024-11-28T19:49:20.739189"
+  } ]
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

공지 사항 ID

title

String

true

공지 사항 제목

content

String

true

공지 사항 내용

hitCount

Number

true

공지 사항 조회수

fixed

Boolean

true

공지 사항 고정 여부

createdAt

String

true

공지 사항 생성일

updatedAt

String

true

공지 사항 수정일

files

Array

true

첨부 파일 목록

files[].id

Number

true

파일 ID

files[].uuid

String

true

파일 고유 식별자

files[].name

String

true

파일 이름

files[].mimeType

String

true

파일 MIME 타입

files[].createdAt

String

true

파일 생성 시간

files[].updatedAt

String

true

파일 수정 시간

+
+
+
+
+
+

공지 사항 삭제 (DELETE /notices/{noticeId})

+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /notices/{noticeId}
ParameterDescription

noticeId

삭제할 공지 사항 ID

+
+
+

HTTP request

+
+
+
DELETE /notices/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/project.html b/src/main/resources/static/docs/project.html new file mode 100644 index 00000000..6e422696 --- /dev/null +++ b/src/main/resources/static/docs/project.html @@ -0,0 +1,3079 @@ + + + + + + + +프로젝트 API + + + + + +
+
+

프로젝트 API

+
+
+
+

프로젝트 조회 (GET /projects)

+
+
+
+

HTTP request

+
+
+
GET /projects HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: optional_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 1759
+
+{
+  "totalPages" : 1,
+  "totalElements" : 2,
+  "first" : true,
+  "last" : true,
+  "size" : 10,
+  "content" : [ {
+    "id" : 1,
+    "thumbnailInfo" : {
+      "id" : 1,
+      "uuid" : "썸네일 uuid 1",
+      "name" : "썸네일 파일 이름 1",
+      "mimeType" : "썸네일 mime 타입 1"
+    },
+    "projectName" : "프로젝트 이름 1",
+    "teamName" : "팀 이름 1",
+    "studentNames" : [ "학생 이름 1", "학생 이름 2" ],
+    "professorNames" : [ "교수 이름 1" ],
+    "projectType" : "STARTUP",
+    "projectCategory" : "BIG_DATA_ANALYSIS",
+    "awardStatus" : "FIRST",
+    "year" : 2023,
+    "likeCount" : 100,
+    "like" : false,
+    "bookMark" : false,
+    "url" : "프로젝트 URL",
+    "description" : "프로젝트 설명"
+  }, {
+    "id" : 2,
+    "thumbnailInfo" : {
+      "id" : 2,
+      "uuid" : "썸네일 uuid 2",
+      "name" : "썸네일 파일 이름 2",
+      "mimeType" : "썸네일 mime 타입 2"
+    },
+    "projectName" : "프로젝트 이름 2",
+    "teamName" : "팀 이름 2",
+    "studentNames" : [ "학생 이름 3", "학생 이름 4" ],
+    "professorNames" : [ "교수 이름 2" ],
+    "projectType" : "LAB",
+    "projectCategory" : "AI_MACHINE_LEARNING",
+    "awardStatus" : "SECOND",
+    "year" : 2023,
+    "likeCount" : 100,
+    "like" : false,
+    "bookMark" : true,
+    "url" : "프로젝트 URL",
+    "description" : "프로젝트 설명"
+  } ],
+  "number" : 0,
+  "sort" : {
+    "empty" : true,
+    "sorted" : false,
+    "unsorted" : true
+  },
+  "numberOfElements" : 2,
+  "pageable" : {
+    "pageNumber" : 0,
+    "pageSize" : 10,
+    "sort" : {
+      "empty" : true,
+      "sorted" : false,
+      "unsorted" : true
+    },
+    "offset" : 0,
+    "paged" : true,
+    "unpaged" : false
+  },
+  "empty" : false
+}
+
+
+
+
+

Query parameters

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterRequiredDescription

title

false

프로젝트 이름

year

false

프로젝트 년도

category

false

프로젝트 카테고리

type

false

프로젝트 타입

page

false

페이지 번호 [default: 0]

size

false

페이지 크기 [default: 10]

sort

false

정렬 기준

+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

content[].id

Number

true

프로젝트 ID

content[].thumbnailInfo

Object

true

썸네일 정보

content[].thumbnailInfo.id

Number

true

썸네일 ID

content[].thumbnailInfo.uuid

String

true

썸네일 UUID

content[].thumbnailInfo.name

String

true

썸네일 파일 이름

content[].thumbnailInfo.mimeType

String

true

썸네일 MIME 타입

content[].projectName

String

true

프로젝트 이름

content[].teamName

String

true

팀 이름

content[].studentNames[]

Array

true

학생 이름

content[].professorNames[]

Array

true

교수 이름

content[].projectType

String

true

프로젝트 타입: RESEARCH_AND_BUSINESS_FOUNDATION, LAB, STARTUP, CLUB

content[].projectCategory

String

true

프로젝트 카테고리: COMPUTER_VISION, SYSTEM_NETWORK, WEB_APPLICATION, SECURITY_SOFTWARE_ENGINEERING, NATURAL_LANGUAGE_PROCESSING, BIG_DATA_ANALYSIS, AI_MACHINE_LEARNING, INTERACTION_AUGMENTED_REALITY

content[].awardStatus

String

true

수상 여부: NONE, FIRST, SECOND, THIRD, FOURTH, FIFTH

content[].year

Number

true

프로젝트 년도

content[].likeCount

Number

true

좋아요 수

content[].like

Boolean

true

좋아요 여부

content[].bookMark

Boolean

true

북마크 여부

content[].url

String

true

프로젝트 URL

content[].description

String

true

프로젝트 설명

pageable

Object

true

페이지 정보

pageable.pageNumber

Number

true

현재 페이지 번호

pageable.pageSize

Number

true

페이지 당 요소 수

pageable.sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

pageable.sort.sorted

Boolean

true

정렬된 상태인지 여부

pageable.sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

pageable.offset

Number

true

오프셋

pageable.paged

Boolean

true

페이징된 여부

pageable.unpaged

Boolean

true

페이징되지 않은 여부

totalElements

Number

true

전체 요소 수

totalPages

Number

true

전체 페이지 수

size

Number

true

페이지 당 요소 수

number

Number

true

현재 페이지 번호

numberOfElements

Number

true

현재 페이지 요소 수

first

Boolean

true

첫 페이지 여부

last

Boolean

true

마지막 페이지 여부

sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

sort.sorted

Boolean

true

정렬된 상태인지 여부

empty

Boolean

true

비어있는 페이지 여부

+
+
+
+
+
+

프로젝트 생성 (POST /projects)

+
+
+
+

HTTP request

+
+
+
POST /projects HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Content-Length: 535
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "thumbnailId" : 1,
+  "posterId" : 2,
+  "projectName" : "프로젝트 이름",
+  "projectType" : "STARTUP",
+  "projectCategory" : "BIG_DATA_ANALYSIS",
+  "teamName" : "팀 이름",
+  "youtubeId" : "유튜브 ID",
+  "year" : 2021,
+  "awardStatus" : "NONE",
+  "members" : [ {
+    "name" : "학생 이름 1",
+    "role" : "STUDENT"
+  }, {
+    "name" : "학생 이름 2",
+    "role" : "STUDENT"
+  }, {
+    "name" : "교수 이름 1",
+    "role" : "PROFESSOR"
+  } ],
+  "url" : "프로젝트 URL",
+  "description" : "프로젝트 설명"
+}
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 1481
+
+{
+  "id" : 1,
+  "thumbnailInfo" : {
+    "id" : 2,
+    "uuid" : "썸네일 uuid",
+    "name" : "썸네일 파일 이름",
+    "mimeType" : "썸네일 mime 타입"
+  },
+  "posterInfo" : {
+    "id" : 2,
+    "uuid" : "포스터 uuid",
+    "name" : "포트서 파일 이름",
+    "mimeType" : "포스터 mime 타입"
+  },
+  "projectName" : "프로젝트 이름",
+  "projectType" : "STARTUP",
+  "projectCategory" : "BIG_DATA_ANALYSIS",
+  "teamName" : "팀 이름",
+  "youtubeId" : "유튜브 ID",
+  "year" : 2024,
+  "awardStatus" : "FIRST",
+  "studentNames" : [ "학생 이름 1", "학생 이름 2" ],
+  "professorNames" : [ "교수 이름 1" ],
+  "likeCount" : 0,
+  "like" : false,
+  "bookMark" : false,
+  "comments" : [ {
+    "id" : 1,
+    "projectId" : 1,
+    "userName" : "유저 이름",
+    "isAnonymous" : true,
+    "content" : "댓글 내용",
+    "createdAt" : "2024-11-28T19:49:21.053603",
+    "updatedAt" : "2024-11-28T19:49:21.053605"
+  }, {
+    "id" : 2,
+    "projectId" : 1,
+    "userName" : "유저 이름",
+    "isAnonymous" : false,
+    "content" : "댓글 내용",
+    "createdAt" : "2024-11-28T19:49:21.053606",
+    "updatedAt" : "2024-11-28T19:49:21.053607"
+  }, {
+    "id" : 3,
+    "projectId" : 1,
+    "userName" : "유저 이름",
+    "isAnonymous" : false,
+    "content" : "댓글 내용",
+    "createdAt" : "2024-11-28T19:49:21.053608",
+    "updatedAt" : "2024-11-28T19:49:21.053608"
+  } ],
+  "url" : "프로젝트 URL",
+  "description" : "프로젝트 설명"
+}
+
+
+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

thumbnailId

Number

true

썸네일 ID

posterId

Number

true

포스터 ID

projectName

String

true

프로젝트 이름

projectType

String

true

프로젝트 타입: RESEARCH_AND_BUSINESS_FOUNDATION, LAB, STARTUP, CLUB

projectCategory

String

true

프로젝트 카테고리: COMPUTER_VISION, SYSTEM_NETWORK, WEB_APPLICATION, SECURITY_SOFTWARE_ENGINEERING, NATURAL_LANGUAGE_PROCESSING, BIG_DATA_ANALYSIS, AI_MACHINE_LEARNING, INTERACTION_AUGMENTED_REALITY

teamName

String

true

팀 이름

youtubeId

String

true

프로젝트 youtubeId

year

Number

true

프로젝트 년도

awardStatus

String

true

수상 여부: NONE, FIRST, SECOND, THIRD, FOURTH, FIFTH

members

Array

true

멤버

members[].name

String

true

멤버 이름

members[].role

String

true

멤버 역할: STUDENT, PROFESSOR

url

String

true

프로젝트 URL

description

String

true

프로젝트 설명

+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

프로젝트 ID

thumbnailInfo

Object

true

썸네일 정보

thumbnailInfo.id

Number

true

썸네일 ID

thumbnailInfo.uuid

String

true

썸네일 UUID

thumbnailInfo.name

String

true

썸네일 파일 이름

thumbnailInfo.mimeType

String

true

썸네일 MIME 타입

posterInfo

Object

true

포스터 정보

posterInfo.id

Number

true

포스터 ID

posterInfo.uuid

String

true

포스터 UUID

posterInfo.name

String

true

포스터 파일 이름

posterInfo.mimeType

String

true

포스터 MIME 타입

projectName

String

true

프로젝트 이름

projectType

String

true

프로젝트 타입: RESEARCH_AND_BUSINESS_FOUNDATION, LAB, STARTUP, CLUB

projectCategory

String

true

프로젝트 카테고리: COMPUTER_VISION, SYSTEM_NETWORK, WEB_APPLICATION, SECURITY_SOFTWARE_ENGINEERING, NATURAL_LANGUAGE_PROCESSING, BIG_DATA_ANALYSIS, AI_MACHINE_LEARNING, INTERACTION_AUGMENTED_REALITY

teamName

String

true

팀 이름

youtubeId

String

true

프로젝트 youtubeId

year

Number

true

프로젝트 년도

awardStatus

String

true

수상 여부: NONE, FIRST, SECOND, THIRD, FOURTH, FIFTH

studentNames

Array

true

학생 이름

professorNames

Array

true

교수 이름

likeCount

Number

true

좋아요 수

like

Boolean

true

좋아요 여부

bookMark

Boolean

true

북마크 여부

url

String

true

프로젝트 URL

description

String

true

프로젝트 설명

comments

Array

true

댓글

comments[].id

Number

true

댓글 ID

comments[].projectId

Number

true

유저 ID

comments[].userName

String

true

유저 이름

comments[].isAnonymous

Boolean

true

익명 여부

comments[].content

String

true

댓글 내용

comments[].createdAt

String

true

생성 시간

comments[].updatedAt

String

true

수정 시간

+
+
+
+
+
+

프로젝트 엑셀 일괄등록 (POST /projects/excel)

+
+
+
+

HTTP request

+
+
+
POST /projects/excel HTTP/1.1
+Content-Type: multipart/form-data;charset=UTF-8; boundary=6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Authorization: admin_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Disposition: form-data; name=excel; filename=project_upload_form.xlsx
+Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
+
+[BINARY DATA]
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Disposition: form-data; name=thumbnails; filename=thumbnails.json
+Content-Type: application/json
+
+[{"id":1,"uuid":"033f326e-3ed6-4279-ba3b-c14b0c7eb0e5","name":"썸네일1.png","mimeType":"image/png","createdAt":"2024-11-28T19:49:21.218223","updatedAt":"2024-11-28T19:49:21.218224"},{"id":1,"uuid":"2d47118a-bd5d-4054-ac0f-f4b4d7cde727","name":"썸네일2.png","mimeType":"image/png","createdAt":"2024-11-28T19:49:21.218241","updatedAt":"2024-11-28T19:49:21.218242"}]
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Disposition: form-data; name=posters; filename=thumbnails.json
+Content-Type: application/json
+
+[{"id":1,"uuid":"7249fab0-93da-440c-b1de-b9c60c79b597","name":"포스터1.png","mimeType":"image/png","createdAt":"2024-11-28T19:49:21.218257","updatedAt":"2024-11-28T19:49:21.218258"},{"id":1,"uuid":"84807b2b-e8c5-43e3-a92a-b14626c9030b","name":"포스터2.png","mimeType":"image/png","createdAt":"2024-11-28T19:49:21.218262","updatedAt":"2024-11-28T19:49:21.218263"}]
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm--
+
+
+
+
+

Request parts

+ ++++ + + + + + + + + + + + + + + + + + + + + +
PartDescription

excel

업로드할 Excel 파일

thumbnails

썸네일 등록 응답 JSON 파일

posters

포스터 등록 응답 JSON 파일

+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 24
+
+{
+  "successCount" : 2
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

successCount

Number

true

생성에 성공한 프로젝트 개수

+
+
+
+
+
+

프로젝트 조회 (GET /projects/{projectId})

+
+
+
+

HTTP request

+
+
+
GET /projects/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: optional_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 1481
+
+{
+  "id" : 1,
+  "thumbnailInfo" : {
+    "id" : 2,
+    "uuid" : "썸네일 uuid",
+    "name" : "썸네일 파일 이름",
+    "mimeType" : "썸네일 mime 타입"
+  },
+  "posterInfo" : {
+    "id" : 2,
+    "uuid" : "포스터 uuid",
+    "name" : "포트서 파일 이름",
+    "mimeType" : "포스터 mime 타입"
+  },
+  "projectName" : "프로젝트 이름",
+  "projectType" : "STARTUP",
+  "projectCategory" : "BIG_DATA_ANALYSIS",
+  "teamName" : "팀 이름",
+  "youtubeId" : "유튜브 ID",
+  "year" : 2024,
+  "awardStatus" : "FIRST",
+  "studentNames" : [ "학생 이름 1", "학생 이름 2" ],
+  "professorNames" : [ "교수 이름 1" ],
+  "likeCount" : 0,
+  "like" : false,
+  "bookMark" : false,
+  "comments" : [ {
+    "id" : 1,
+    "projectId" : 1,
+    "userName" : "유저 이름",
+    "isAnonymous" : true,
+    "content" : "댓글 내용",
+    "createdAt" : "2024-11-28T19:49:21.029498",
+    "updatedAt" : "2024-11-28T19:49:21.029501"
+  }, {
+    "id" : 2,
+    "projectId" : 1,
+    "userName" : "유저 이름",
+    "isAnonymous" : false,
+    "content" : "댓글 내용",
+    "createdAt" : "2024-11-28T19:49:21.029504",
+    "updatedAt" : "2024-11-28T19:49:21.029505"
+  }, {
+    "id" : 3,
+    "projectId" : 1,
+    "userName" : "유저 이름",
+    "isAnonymous" : false,
+    "content" : "댓글 내용",
+    "createdAt" : "2024-11-28T19:49:21.029506",
+    "updatedAt" : "2024-11-28T19:49:21.029506"
+  } ],
+  "url" : "프로젝트 URL",
+  "description" : "프로젝트 설명"
+}
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /projects/{projectId}
ParameterDescription

projectId

프로젝트 ID

+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

프로젝트 ID

thumbnailInfo

Object

true

썸네일 정보

thumbnailInfo.id

Number

true

썸네일 ID

thumbnailInfo.uuid

String

true

썸네일 UUID

thumbnailInfo.name

String

true

썸네일 파일 이름

thumbnailInfo.mimeType

String

true

썸네일 MIME 타입

posterInfo

Object

true

포스터 정보

posterInfo.id

Number

true

포스터 ID

posterInfo.uuid

String

true

포스터 UUID

posterInfo.name

String

true

포스터 파일 이름

posterInfo.mimeType

String

true

포스터 MIME 타입

projectName

String

true

프로젝트 이름

projectType

String

true

프로젝트 타입: RESEARCH_AND_BUSINESS_FOUNDATION, LAB, STARTUP, CLUB

projectCategory

String

true

프로젝트 카테고리: COMPUTER_VISION, SYSTEM_NETWORK, WEB_APPLICATION, SECURITY_SOFTWARE_ENGINEERING, NATURAL_LANGUAGE_PROCESSING, BIG_DATA_ANALYSIS, AI_MACHINE_LEARNING, INTERACTION_AUGMENTED_REALITY

teamName

String

true

팀 이름

youtubeId

String

true

프로젝트 youtubeId

year

Number

true

프로젝트 년도

awardStatus

String

true

수상 여부: NONE, FIRST, SECOND, THIRD, FOURTH, FIFTH

studentNames

Array

true

학생 이름

professorNames

Array

true

교수 이름

likeCount

Number

true

좋아요 수

like

Boolean

true

좋아요 여부

bookMark

Boolean

true

북마크 여부

url

String

true

프로젝트 URL

description

String

true

프로젝트 설명

comments

Array

true

댓글

comments[].id

Number

true

댓글 ID

comments[].projectId

Number

true

유저 ID

comments[].userName

String

true

유저 이름

comments[].isAnonymous

Boolean

true

익명 여부

comments[].content

String

true

댓글 내용

comments[].createdAt

String

true

생성 시간

comments[].updatedAt

String

true

수정 시간

+
+
+
+
+
+

프로젝트 수정 (PUT /projects/{projectId})

+
+
+
+

HTTP request

+
+
+
PUT /projects/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Content-Length: 530
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "thumbnailId" : 3,
+  "posterId" : 4,
+  "projectName" : "프로젝트 이름",
+  "projectType" : "LAB",
+  "projectCategory" : "COMPUTER_VISION",
+  "teamName" : "팀 이름",
+  "youtubeId" : "유튜브 ID",
+  "year" : 2024,
+  "awardStatus" : "FIRST",
+  "members" : [ {
+    "name" : "학생 이름 3",
+    "role" : "STUDENT"
+  }, {
+    "name" : "학생 이름 4",
+    "role" : "STUDENT"
+  }, {
+    "name" : "교수 이름 2",
+    "role" : "PROFESSOR"
+  } ],
+  "url" : "프로젝트 URL",
+  "description" : "프로젝트 설명"
+}
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 1477
+
+{
+  "id" : 1,
+  "thumbnailInfo" : {
+    "id" : 3,
+    "uuid" : "썸네일 uuid",
+    "name" : "썸네일 파일 이름",
+    "mimeType" : "썸네일 mime 타입"
+  },
+  "posterInfo" : {
+    "id" : 4,
+    "uuid" : "포스터 uuid",
+    "name" : "포트서 파일 이름",
+    "mimeType" : "포스터 mime 타입"
+  },
+  "projectName" : "프로젝트 이름",
+  "projectType" : "LAB",
+  "projectCategory" : "COMPUTER_VISION",
+  "teamName" : "팀 이름",
+  "youtubeId" : "유튜브 ID",
+  "year" : 2024,
+  "awardStatus" : "FIRST",
+  "studentNames" : [ "학생 이름 3", "학생 이름 4" ],
+  "professorNames" : [ "교수 이름 2" ],
+  "likeCount" : 100,
+  "like" : false,
+  "bookMark" : false,
+  "comments" : [ {
+    "id" : 1,
+    "projectId" : 1,
+    "userName" : "유저 이름",
+    "isAnonymous" : true,
+    "content" : "댓글 내용",
+    "createdAt" : "2024-11-28T19:49:20.940585",
+    "updatedAt" : "2024-11-28T19:49:20.940587"
+  }, {
+    "id" : 2,
+    "projectId" : 1,
+    "userName" : "유저 이름",
+    "isAnonymous" : false,
+    "content" : "댓글 내용",
+    "createdAt" : "2024-11-28T19:49:20.940592",
+    "updatedAt" : "2024-11-28T19:49:20.940593"
+  }, {
+    "id" : 3,
+    "projectId" : 1,
+    "userName" : "유저 이름",
+    "isAnonymous" : false,
+    "content" : "댓글 내용",
+    "createdAt" : "2024-11-28T19:49:20.940595",
+    "updatedAt" : "2024-11-28T19:49:20.940596"
+  } ],
+  "url" : "프로젝트 URL",
+  "description" : "프로젝트 설명"
+}
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /projects/{projectId}
ParameterDescription

projectId

프로젝트 ID

+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

thumbnailId

Number

true

썸네일 ID

posterId

Number

true

포스터 ID

projectName

String

true

프로젝트 이름

projectType

String

true

프로젝트 타입: RESEARCH_AND_BUSINESS_FOUNDATION, LAB, STARTUP, CLUB

projectCategory

String

true

프로젝트 카테고리: COMPUTER_VISION, SYSTEM_NETWORK, WEB_APPLICATION, SECURITY_SOFTWARE_ENGINEERING, NATURAL_LANGUAGE_PROCESSING, BIG_DATA_ANALYSIS, AI_MACHINE_LEARNING, INTERACTION_AUGMENTED_REALITY

teamName

String

true

팀 이름

youtubeId

String

true

프로젝트 youtubeId

year

Number

true

프로젝트 년도

awardStatus

String

true

수상 여부: NONE, FIRST, SECOND, THIRD, FOURTH, FIFTH

members

Array

true

멤버

members[].name

String

true

멤버 이름

members[].role

String

true

멤버 역할: STUDENT, PROFESSOR

url

String

true

프로젝트 URL

description

String

true

프로젝트 설명

+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

프로젝트 ID

thumbnailInfo

Object

true

썸네일 정보

thumbnailInfo.id

Number

true

썸네일 ID

thumbnailInfo.uuid

String

true

썸네일 UUID

thumbnailInfo.name

String

true

썸네일 파일 이름

thumbnailInfo.mimeType

String

true

썸네일 MIME 타입

posterInfo

Object

true

포스터 정보

posterInfo.id

Number

true

포스터 ID

posterInfo.uuid

String

true

포스터 UUID

posterInfo.name

String

true

포스터 파일 이름

posterInfo.mimeType

String

true

포스터 MIME 타입

projectName

String

true

프로젝트 이름

projectType

String

true

프로젝트 타입: RESEARCH_AND_BUSINESS_FOUNDATION, LAB, STARTUP, CLUB

projectCategory

String

true

프로젝트 카테고리: COMPUTER_VISION, SYSTEM_NETWORK, WEB_APPLICATION, SECURITY_SOFTWARE_ENGINEERING, NATURAL_LANGUAGE_PROCESSING, BIG_DATA_ANALYSIS, AI_MACHINE_LEARNING, INTERACTION_AUGMENTED_REALITY

teamName

String

true

팀 이름

youtubeId

String

true

프로젝트 youtubeId

year

Number

true

프로젝트 년도

awardStatus

String

true

수상 여부: NONE, FIRST, SECOND, THIRD, FOURTH, FIFTH

studentNames

Array

true

학생 이름

professorNames

Array

true

교수 이름

likeCount

Number

true

좋아요 수

like

Boolean

true

좋아요 여부

bookMark

Boolean

true

북마크 여부

url

String

true

프로젝트 URL

description

String

true

프로젝트 설명

comments

Array

true

댓글

comments[].id

Number

true

댓글 ID

comments[].projectId

Number

true

유저 ID

comments[].userName

String

true

유저 이름

comments[].isAnonymous

Boolean

true

익명 여부

comments[].content

String

true

댓글 내용

comments[].createdAt

String

true

생성 시간

comments[].updatedAt

String

true

수정 시간

+
+
+
+
+
+

프로젝트 삭제 (DELETE /projects/{projectId})

+
+
+
+

HTTP request

+
+
+
DELETE /projects/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /projects/{projectId}
ParameterDescription

projectId

프로젝트 ID

+
+
+
+
+
+

관심 프로젝트 등록 (POST /projects/{projectId}/favorite)

+
+
+
+

HTTP request

+
+
+
POST /projects/1/favorite HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: all_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /projects/{projectId}/favorite
ParameterDescription

projectId

프로젝트 ID

+
+
+
+
+
+

관심 프로젝트 삭제 (DELETE /projects/{projectId}/favorite)

+
+
+
+

HTTP request

+
+
+
DELETE /projects/1/favorite HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: all_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /projects/{projectId}/favorite
ParameterDescription

projectId

프로젝트 ID

+
+
+
+
+
+

프로젝트 좋아요 등록 (POST /projects/{projectId}/like)

+
+
+
+

HTTP request

+
+
+
POST /projects/1/like HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: all_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /projects/{projectId}/like
ParameterDescription

projectId

프로젝트 ID

+
+
+
+
+
+

프로젝트 좋아요 삭제 (DELETE /projects/{projectId}/like)

+
+
+
+

HTTP request

+
+
+
DELETE /projects/1/like HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: all_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /projects/{projectId}/like
ParameterDescription

projectId

프로젝트 ID

+
+
+
+
+
+

프로젝트 댓글 등록 (POST /projects/{projectId}/comment)

+
+
+
+

HTTP request

+
+
+
POST /projects/1/comment HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: all_access_token
+Content-Length: 57
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "content" : "댓글 내용",
+  "isAnonymous" : true
+}
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 211
+
+{
+  "id" : 1,
+  "projectId" : 1,
+  "userName" : "유저 이름",
+  "isAnonymous" : true,
+  "content" : "댓글 내용",
+  "createdAt" : "2024-11-28T19:49:21.02064",
+  "updatedAt" : "2024-11-28T19:49:21.020642"
+}
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /projects/{projectId}/comment
ParameterDescription

projectId

프로젝트 ID

+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

content

String

true

댓글 내용

isAnonymous

Boolean

true

익명 여부

+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

댓글 ID

projectId

Number

true

프로젝트 ID

userName

String

true

유저 이름

isAnonymous

Boolean

true

익명 여부

content

String

true

댓글 내용

createdAt

String

true

생성 시간

updatedAt

String

true

수정 시간

+
+
+
+
+
+

프로젝트 댓글 삭제 (DELETE /projects/{projectId}/comment)

+
+
+
+

HTTP request

+
+
+
DELETE /projects/1/comment/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: all_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + + + + + +
Table 1. /projects/{projectId}/comment/{commentId}
ParameterDescription

projectId

프로젝트 ID

commentId

댓글 ID

+
+
+
+
+
+

수상 프로젝트 조회 (GET /projects/award?year={year})

+
+
+
+

HTTP request

+
+
+
GET /projects/award?year=2024 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: optional_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 1759
+
+{
+  "totalPages" : 1,
+  "totalElements" : 2,
+  "first" : true,
+  "last" : true,
+  "size" : 10,
+  "content" : [ {
+    "id" : 1,
+    "thumbnailInfo" : {
+      "id" : 1,
+      "uuid" : "썸네일 uuid 1",
+      "name" : "썸네일 파일 이름 1",
+      "mimeType" : "썸네일 mime 타입 1"
+    },
+    "projectName" : "프로젝트 이름 1",
+    "teamName" : "팀 이름 1",
+    "studentNames" : [ "학생 이름 1", "학생 이름 2" ],
+    "professorNames" : [ "교수 이름 1" ],
+    "projectType" : "STARTUP",
+    "projectCategory" : "BIG_DATA_ANALYSIS",
+    "awardStatus" : "FIRST",
+    "year" : 2023,
+    "likeCount" : 100,
+    "like" : false,
+    "bookMark" : false,
+    "url" : "프로젝트 URL",
+    "description" : "프로젝트 설명"
+  }, {
+    "id" : 2,
+    "thumbnailInfo" : {
+      "id" : 2,
+      "uuid" : "썸네일 uuid 2",
+      "name" : "썸네일 파일 이름 2",
+      "mimeType" : "썸네일 mime 타입 2"
+    },
+    "projectName" : "프로젝트 이름 2",
+    "teamName" : "팀 이름 2",
+    "studentNames" : [ "학생 이름 3", "학생 이름 4" ],
+    "professorNames" : [ "교수 이름 2" ],
+    "projectType" : "LAB",
+    "projectCategory" : "AI_MACHINE_LEARNING",
+    "awardStatus" : "SECOND",
+    "year" : 2023,
+    "likeCount" : 100,
+    "like" : false,
+    "bookMark" : true,
+    "url" : "프로젝트 URL",
+    "description" : "프로젝트 설명"
+  } ],
+  "number" : 0,
+  "sort" : {
+    "empty" : true,
+    "sorted" : false,
+    "unsorted" : true
+  },
+  "numberOfElements" : 2,
+  "pageable" : {
+    "pageNumber" : 0,
+    "pageSize" : 10,
+    "sort" : {
+      "empty" : true,
+      "sorted" : false,
+      "unsorted" : true
+    },
+    "offset" : 0,
+    "paged" : true,
+    "unpaged" : false
+  },
+  "empty" : false
+}
+
+
+
+
+

Query parameters

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterRequiredDescription

year

true

프로젝트 년도

page

false

페이지 번호 [default: 0]

size

false

페이지 크기 [default: 10]

sort

false

정렬 기준

+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

content[].id

Number

true

프로젝트 ID

content[].thumbnailInfo

Object

true

썸네일 정보

content[].thumbnailInfo.id

Number

true

썸네일 ID

content[].thumbnailInfo.uuid

String

true

썸네일 UUID

content[].thumbnailInfo.name

String

true

썸네일 파일 이름

content[].thumbnailInfo.mimeType

String

true

썸네일 MIME 타입

content[].projectName

String

true

프로젝트 이름

content[].teamName

String

true

팀 이름

content[].studentNames[]

Array

true

학생 이름

content[].professorNames[]

Array

true

교수 이름

content[].projectType

String

true

프로젝트 타입: RESEARCH_AND_BUSINESS_FOUNDATION, LAB, STARTUP, CLUB

content[].projectCategory

String

true

프로젝트 카테고리: COMPUTER_VISION, SYSTEM_NETWORK, WEB_APPLICATION, SECURITY_SOFTWARE_ENGINEERING, NATURAL_LANGUAGE_PROCESSING, BIG_DATA_ANALYSIS, AI_MACHINE_LEARNING, INTERACTION_AUGMENTED_REALITY

content[].awardStatus

String

true

수상 여부: NONE, FIRST, SECOND, THIRD, FOURTH, FIFTH

content[].year

Number

true

프로젝트 년도

content[].likeCount

Number

true

좋아요 수

content[].like

Boolean

true

좋아요 여부

content[].bookMark

Boolean

true

북마크 여부

content[].url

String

true

프로젝트 URL

content[].description

String

true

프로젝트 설명

pageable

Object

true

페이지 정보

pageable.pageNumber

Number

true

현재 페이지 번호

pageable.pageSize

Number

true

페이지 당 요소 수

pageable.sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

pageable.sort.sorted

Boolean

true

정렬된 상태인지 여부

pageable.sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

pageable.offset

Number

true

오프셋

pageable.paged

Boolean

true

페이징된 여부

pageable.unpaged

Boolean

true

페이징되지 않은 여부

totalElements

Number

true

전체 요소 수

totalPages

Number

true

전체 페이지 수

size

Number

true

페이지 당 요소 수

number

Number

true

현재 페이지 번호

numberOfElements

Number

true

현재 페이지 요소 수

first

Boolean

true

첫 페이지 여부

last

Boolean

true

마지막 페이지 여부

sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

sort.sorted

Boolean

true

정렬된 상태인지 여부

empty

Boolean

true

비어있는 페이지 여부

+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/proposal.html b/src/main/resources/static/docs/proposal.html new file mode 100644 index 00000000..d0485e81 --- /dev/null +++ b/src/main/resources/static/docs/proposal.html @@ -0,0 +1,1935 @@ + + + + + + + +과제 제안 API + + + + + +
+
+

과제 제안 API

+
+
+

과제 제안 생성 (POST /proposals)

+
+
+
+

HTTP request

+
+
+
POST /proposals HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_or_company_access_token
+Content-Length: 247
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "webSite" : "website.com",
+  "title" : "과제제안제목",
+  "email" : "이메일@email.com",
+  "projectTypes" : [ "LAB", "CLUB" ],
+  "content" : "과제제안내용",
+  "isVisible" : true,
+  "isAnonymous" : true,
+  "fileIds" : [ 1, 2, 3 ]
+}
+
+
+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

과제제안 제목

content

String

true

과제제안 내용

webSite

String

false

과제제안 사이트

projectTypes

Array

true

과제제안 프로젝트 유형:RESEARCH_AND_BUSINESS_FOUNDATION, LAB, STARTUP, CLUB

email

String

true

과제제안 이메일

isVisible

Boolean

true

공개 여부

isAnonymous

Boolean

true

익명 여부

fileIds

Array

true

파일 id 리스트

+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 906
+
+{
+  "id" : 1,
+  "authorName" : "작성자",
+  "email" : "이메일@email.com",
+  "webSite" : "website.com",
+  "title" : "과제제안제목",
+  "projectTypes" : [ "LAB", "CLUB" ],
+  "content" : "과제제안내용",
+  "replied" : false,
+  "files" : [ {
+    "id" : 1,
+    "uuid" : "uuid1",
+    "name" : "과제제안서 이름1.pdf",
+    "mimeType" : "application/pdf",
+    "createdAt" : "2024-11-28T19:49:21.437367",
+    "updatedAt" : "2024-11-28T19:49:21.43737"
+  }, {
+    "id" : 2,
+    "uuid" : "uuid2",
+    "name" : "과제제안서 이름2.pdf",
+    "mimeType" : "application/pdf",
+    "createdAt" : "2024-11-28T19:49:21.437371",
+    "updatedAt" : "2024-11-28T19:49:21.437372"
+  }, {
+    "id" : 3,
+    "uuid" : "uuid3",
+    "name" : "과제제안서 이름3.pdf",
+    "mimeType" : "application/pdf",
+    "createdAt" : "2024-11-28T19:49:21.437372",
+    "updatedAt" : "2024-11-28T19:49:21.437373"
+  } ]
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

과제 제안 ID

authorName

String

true

과제 제안 작성자

email

String

true

과제 제안 이메일

webSite

String

true

과제 제안 사이트

title

String

true

과제 제안 제목

projectTypes

Array

true

과제 제안 프로젝트 유형들

content

String

true

과제 제안 내용

replied

Boolean

true

과제 제안 답변 유무

files

Array

true

파일 목록

files[].id

Number

true

파일 ID

files[].uuid

String

true

파일 UUID

files[].name

String

true

파일 이름

files[].mimeType

String

true

파일 MIME 타입

files[].createdAt

String

true

파일 생성일

files[].updatedAt

String

true

파일 수정일

+
+
+
+
+
+

과제 제안 리스트 조회 (GET /proposals)

+
+
+
+

Query parameters

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterRequiredDescription

scope

false

필터 조건 (title,content,both,author)

term

false

필터 키워드

page

false

페이지 번호 [default = 0]

size

false

페이지 크기 [default = 10]

+
+
+

HTTP request

+
+
+
GET /proposals HTTP/1.1
+Authorization: all_user_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 723
+
+{
+  "totalPages" : 1,
+  "totalElements" : 2,
+  "first" : true,
+  "last" : true,
+  "size" : 10,
+  "content" : [ {
+    "id" : 1,
+    "title" : "과제 제안1",
+    "name" : "이름",
+    "createdDate" : "2024-11-28T19:49:21.399833"
+  }, {
+    "id" : 2,
+    "title" : "과제 제안2",
+    "name" : "이름",
+    "createdDate" : "2024-11-28T19:49:21.399862"
+  } ],
+  "number" : 0,
+  "sort" : {
+    "empty" : true,
+    "sorted" : false,
+    "unsorted" : true
+  },
+  "numberOfElements" : 2,
+  "pageable" : {
+    "pageNumber" : 0,
+    "pageSize" : 10,
+    "sort" : {
+      "empty" : true,
+      "sorted" : false,
+      "unsorted" : true
+    },
+    "offset" : 0,
+    "paged" : true,
+    "unpaged" : false
+  },
+  "empty" : false
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

content[].id

Number

true

과제 제안 ID

content[].title

String

true

과제 제안 제목

content[].name

String

true

과제 제안 작성자

content[].createdDate

String

true

과제 제안 생성일

pageable

Object

true

페이지 정보

pageable.pageNumber

Number

true

현재 페이지 번호

pageable.pageSize

Number

true

페이지 당 요소 수

pageable.sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

pageable.sort.sorted

Boolean

true

정렬된 상태인지 여부

pageable.sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

pageable.offset

Number

true

오프셋

pageable.paged

Boolean

true

페이징된 여부

pageable.unpaged

Boolean

true

페이징되지 않은 여부

totalElements

Number

true

전체 요소 수

totalPages

Number

true

전체 페이지 수

size

Number

true

페이지 당 요소 수

number

Number

true

현재 페이지 번호

numberOfElements

Number

true

현재 페이지 요소 수

first

Boolean

true

첫 페이지 여부

last

Boolean

true

마지막 페이지 여부

sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

sort.sorted

Boolean

true

정렬된 상태인지 여부

empty

Boolean

true

비어있는 페이지 여부

+
+
+
+
+
+

과제 제안 상세 조회 (GET /proposals/{proposalId})

+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /proposals/{proposalId}
ParameterDescription

proposalId

수정할 과제제안 ID

+
+
+

HTTP request

+
+
+
GET /proposals/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_or_company_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 955
+
+{
+  "id" : 1,
+  "authorName" : "작성자",
+  "email" : "작성자@email.com",
+  "webSite" : "website.com",
+  "title" : "과제 제안 제목",
+  "projectTypes" : [ "LAB", "CLUB", "STARTUP", "RESEARCH_AND_BUSINESS_FOUNDATION" ],
+  "content" : "과제제안 내용",
+  "replied" : false,
+  "files" : [ {
+    "id" : 1,
+    "uuid" : "uuid1",
+    "name" : "과제제안서 이름1.pdf",
+    "mimeType" : "application/pdf",
+    "createdAt" : "2024-11-28T19:49:21.445892",
+    "updatedAt" : "2024-11-28T19:49:21.445894"
+  }, {
+    "id" : 2,
+    "uuid" : "uuid2",
+    "name" : "과제제안서 이름2.pdf",
+    "mimeType" : "application/pdf",
+    "createdAt" : "2024-11-28T19:49:21.445897",
+    "updatedAt" : "2024-11-28T19:49:21.445898"
+  }, {
+    "id" : 3,
+    "uuid" : "uuid3",
+    "name" : "과제제안서 이름3.pdf",
+    "mimeType" : "application/pdf",
+    "createdAt" : "2024-11-28T19:49:21.445899",
+    "updatedAt" : "2024-11-28T19:49:21.4459"
+  } ]
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

과제 제안 ID

authorName

String

true

과제 제안 작성자

email

String

true

과제 제안 이메일

webSite

String

true

과제 제안 사이트

title

String

true

과제 제안 제목

projectTypes

Array

true

과제 제안 프로젝트 유형들

content

String

true

과제 제안 내용

replied

Boolean

true

과제 제안 답변 유무

files

Array

true

파일 목록

files[].id

Number

true

파일 ID

files[].uuid

String

true

파일 UUID

files[].name

String

true

파일 이름

files[].mimeType

String

true

파일 MIME 타입

files[].createdAt

String

true

파일 생성일

files[].updatedAt

String

true

파일 수정일

+
+
+
+
+
+

과제 제안 수정 (PUT /proposals/{proposalId})

+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /proposals/{proposalId}
ParameterDescription

proposalId

수정할 과제제안 ID

+
+
+

HTTP request

+
+
+
PUT /proposals/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_or_company_access_token
+Content-Length: 247
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "webSite" : "website.com",
+  "title" : "과제제안제목",
+  "email" : "이메일@email.com",
+  "projectTypes" : [ "LAB", "CLUB" ],
+  "content" : "과제제안내용",
+  "isVisible" : true,
+  "isAnonymous" : true,
+  "fileIds" : [ 1, 2, 3 ]
+}
+
+
+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

과제제안 제목

content

String

true

과제제안 내용

webSite

String

false

과제제안 사이트

projectTypes

Array

true

과제제안 프로젝트 유형:RESEARCH_AND_BUSINESS_FOUNDATION, LAB, STARTUP, CLUB

email

String

true

과제제안 이메일

isVisible

Boolean

true

공개 여부

isAnonymous

Boolean

true

익명 여부

fileIds

Array

true

파일 id 리스트

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 906
+
+{
+  "id" : 1,
+  "authorName" : "작성자",
+  "email" : "이메일@email.com",
+  "webSite" : "website.com",
+  "title" : "과제제안제목",
+  "projectTypes" : [ "LAB", "CLUB" ],
+  "content" : "과제제안내용",
+  "replied" : false,
+  "files" : [ {
+    "id" : 1,
+    "uuid" : "uuid1",
+    "name" : "과제제안서 이름1.pdf",
+    "mimeType" : "application/pdf",
+    "createdAt" : "2024-11-28T19:49:21.423858",
+    "updatedAt" : "2024-11-28T19:49:21.42386"
+  }, {
+    "id" : 2,
+    "uuid" : "uuid2",
+    "name" : "과제제안서 이름2.pdf",
+    "mimeType" : "application/pdf",
+    "createdAt" : "2024-11-28T19:49:21.423864",
+    "updatedAt" : "2024-11-28T19:49:21.423865"
+  }, {
+    "id" : 3,
+    "uuid" : "uuid3",
+    "name" : "과제제안서 이름3.pdf",
+    "mimeType" : "application/pdf",
+    "createdAt" : "2024-11-28T19:49:21.423867",
+    "updatedAt" : "2024-11-28T19:49:21.423868"
+  } ]
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

과제 제안 ID

authorName

String

true

과제 제안 작성자

email

String

true

과제 제안 이메일

webSite

String

true

과제 제안 사이트

title

String

true

과제 제안 제목

projectTypes

Array

true

과제 제안 프로젝트 유형들

content

String

true

과제 제안 내용

replied

Boolean

true

과제 제안 답변 유무

files

Array

true

파일 목록

files[].id

Number

true

파일 ID

files[].uuid

String

true

파일 UUID

files[].name

String

true

파일 이름

files[].mimeType

String

true

파일 MIME 타입

files[].createdAt

String

true

파일 생성일

files[].updatedAt

String

true

파일 수정일

+
+
+
+
+
+

과제 제안 삭제 (DELETE /proposals/{proposalId})

+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /proposals/{proposalId}
ParameterDescription

proposalId

삭제할 과제제안 ID

+
+
+

HTTP request

+
+
+
DELETE /proposals/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_or_company_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+
+

과제 제안 답변 조회 (GET /proposals/{proposalId}/reply)

+
+
+
+

Query parameters

+
+

Snippet query-parameters not found for operation::proposal-controller-test/get-proposal-replies

+
+
+
+

HTTP request

+
+
+
GET /proposals/1/reply HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_or_company_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 100
+
+{
+  "id" : 1,
+  "title" : "과제제안 답변 제목",
+  "content" : "과제제안 답변 내용"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

과제 제안 답변 ID

title

String

true

과제 제안 답변 제목

content

String

true

과제 제안 답변 내용

+
+
+
+
+
+

과제 제안 답변 생성 (PUT /proposals/{proposalId}/reply/)

+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /proposals/{proposalId}/reply
ParameterDescription

proposalId

해당 과제제안 ID

+
+
+

HTTP request

+
+
+
POST /proposals/1/reply HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_or_company_access_token
+Content-Length: 62
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "title" : "답변 제목",
+  "content" : "답변 내용"
+}
+
+
+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

과제제안 답변 제목

content

String

true

과제제안 답변 내용

+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 74
+
+{
+  "id" : 1,
+  "title" : "답변 제목",
+  "content" : "답변 내용"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

과제 제안 답변 ID

title

String

true

과제 제안 답변 제목

content

String

true

과제 제안 답변 내용

+
+
+
+
+
+

과제 제안 답변 수정 (PUT /proposals/{proposalId}/reply/{replyId})

+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + + + + + +
Table 1. /proposals/{proposalId}/reply/{replyId}
ParameterDescription

proposalId

해당 과제제안 ID

replyId

과제 제안 답변 ID

+
+
+

HTTP request

+
+
+
PUT /proposals/1/reply/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_or_company_access_token
+Content-Length: 62
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "title" : "답변 제목",
+  "content" : "답변 내용"
+}
+
+
+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

과제제안 답변 제목

content

String

true

과제제안 답변 내용

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 74
+
+{
+  "id" : 1,
+  "title" : "답변 제목",
+  "content" : "답변 내용"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

과제 제안 답변 ID

title

String

true

과제 제안 답변 제목

content

String

true

과제 제안 답변 내용

+
+
+
+
+
+

과제 제안 답변 삭제 (DELETE /proposals/{proposalId}/reply/{replyId})

+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + + + + + +
Table 1. /proposals/{proposalId}/reply/{proposalReplyId}
ParameterDescription

proposalId

해당 과제제안 ID

proposalReplyId

삭제할 과제제안 답변 ID

+
+
+

HTTP request

+
+
+
DELETE /proposals/1/reply/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/quiz.html b/src/main/resources/static/docs/quiz.html new file mode 100644 index 00000000..117146da --- /dev/null +++ b/src/main/resources/static/docs/quiz.html @@ -0,0 +1,801 @@ + + + + + + + +퀴즈 결과 API + + + + + +
+
+

퀴즈 결과 API

+
+
+
+

퀴즈 결과 조회 (GET /quizzes/result)

+
+
+
+

HTTP request

+
+
+
GET /quizzes/result HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Query parameters

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterRequiredDescription

year

false

찾고자 하는 퀴즈 푼 문제의 연도

page

false

페이지 번호 [default: 0]

size

false

페이지 크기 [default: 10]

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 739
+
+{
+  "totalPages" : 1,
+  "totalElements" : 2,
+  "first" : true,
+  "last" : true,
+  "size" : 10,
+  "content" : [ {
+    "userId" : 1,
+    "name" : "name",
+    "phone" : "010-1111-1111",
+    "email" : "scg@scg.skku.ac.kr",
+    "successCount" : 3
+  }, {
+    "userId" : 2,
+    "name" : "name2",
+    "phone" : "010-0000-1234",
+    "email" : "iam@2tle.io",
+    "successCount" : 1
+  } ],
+  "number" : 0,
+  "sort" : {
+    "empty" : true,
+    "sorted" : false,
+    "unsorted" : true
+  },
+  "numberOfElements" : 2,
+  "pageable" : {
+    "pageNumber" : 0,
+    "pageSize" : 10,
+    "sort" : {
+      "empty" : true,
+      "sorted" : false,
+      "unsorted" : true
+    },
+    "offset" : 0,
+    "paged" : true,
+    "unpaged" : false
+  },
+  "empty" : false
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

pageable

Object

true

페이지 정보

pageable.pageNumber

Number

true

현재 페이지 번호

pageable.pageSize

Number

true

페이지 당 요소 수

pageable.sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

pageable.sort.sorted

Boolean

true

정렬된 상태인지 여부

pageable.sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

pageable.offset

Number

true

오프셋

pageable.paged

Boolean

true

페이징된 여부

pageable.unpaged

Boolean

true

페이징되지 않은 여부

totalElements

Number

true

전체 요소 수

totalPages

Number

true

전체 페이지 수

size

Number

true

페이지 당 요소 수

number

Number

true

현재 페이지 번호

numberOfElements

Number

true

현재 페이지 요소 수

first

Boolean

true

첫 페이지 여부

last

Boolean

true

마지막 페이지 여부

sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

sort.sorted

Boolean

true

정렬된 상태인지 여부

empty

Boolean

true

비어있는 페이지 여부

content[].userId

Number

true

유저의 아이디

content[].name

String

true

유저의 이름

content[].phone

String

true

유저의 연락처

content[].email

String

true

유저의 이메일

content[].successCount

Number

true

유저가 퀴즈를 성공한 횟수의 합

+
+
+
+
+
+

퀴즈 결과를 엑셀로 받기 (GET /quizzes/result/excel)

+
+
+
+

HTTP request

+
+
+
GET /quizzes/result/excel HTTP/1.1
+Content-Type: application/octet-stream;charset=UTF-8
+Authorization: admin_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Query parameters

+ +++++ + + + + + + + + + + + + + + +
ParameterRequiredDescription

year

false

찾고자 하는 퀴즈 푼 문제의 연도, 기본값 올해

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/octet-stream;charset=UTF-8
+Content-Disposition: attachment; filename=excel.xlsx
+Content-Length: 2821
+
+PK-[Content_Types].xml�S�n1��U��&����X8�ql�J?�M��y)1��^�RT�S��xfl%��ڻj���q+G� ���k����~U!\؈�t2�o��[CiDO��*�GEƄ��6f�e�T����ht�t��j4�d��-,UO��A�����S�U0G��^Pft[N�m*7L�˚Uv�0Z�:��q�������E�mk5����[$�M�23Y��A�7�,��<c�(���x֢cƳ�E�GӖ�L��;Yz�h>(�c�b��/�s�Ɲ��`�\s|J6�r��y���௶�j�PK�q,-�PK-_rels/.rels���j�0�_���8�`�Q��2�m��4[ILb��ږ���.[K
+�($}��v?�I�Q.���uӂ�h���x>=��@��p�H"�~�}�	�n����*"�H�׺؁�����8�Z�^'�#��7m{��O�3���G�u�ܓ�'��y|a�����D�	��l_EYȾ����vql3�ML�eh���*���\3�Y0���oJ׏�	:��^��}PK��z��IPK-docProps/app.xmlM��
+�0D�~EȽ��ADҔ���A? ��6�lB�J?ߜ���0���ͯ�)�@��׍H6���V>��$;�SC
+;̢(�ra�g�l�&�e��L!y�%��49��`_���4G���F��J��Wg
�GS�b����
+~�PK�|wؑ�PK-docProps/core.xmlm�]K�0��J�}{�n�m�(Aq�D�.$Ƕ�|�D;��i�Ի$�sN�j{�yGzkj��4Ҫ޴5}8�
%!
+��`
��X�m*鸴��:���@��.]M��C-B���뵈��[pB���,�3��Q�$��b�'���ҽ�a(	8�F���������,�1�5�c>�f.m�����~^>���u���Nj.=���$�.5�<�.�;ڔE����́|}���_���l}s�
+���'ny��O��'PKqBj�PK-xl/sharedStrings.xml=�A� ツ��.z0Ɣ�`������,�����q2��o�ԇ���N�E��x5�z>�W���(R�K���^4{�����ŀ�5��y�V����y�m�XV�\�.�j����
8�PKp��&x�PK-
xl/styles.xml���n� ��>bop2TQ��P)U�RWb�6*�����ӤS�Nw�s���3ߍ֐���t��(l��������ҝx�!N=@$ɀ��}��3c���ʰr`:i��2��w,�
+�d
�T��R#�voc �;c�iE��Û��E<|��4Iɣ����F#��n���B�z�F���y�j3y��yҥ�jt>���2��Lژ�!6��2F�OY��4@M�!���G��������1�t��y��p��"	n����u�����a�ΦDi�9�&#��%I��9��}���cK��T��$?������`J������7���o��f��M|PK�1X@C�PK-xl/workbook.xmlM���0��>E�wi1ƨ����z/�HmI�_j��qf��)ʧٌ��w�LC��ָ��[u��T�b�a��؊;���8�9��G�)��5�|�:�2<8MuK=b�#�	q�V�u
����K��H\)�\�&�t͌��%���?��B��T�PK	���PK-xl/_rels/workbook.xml.rels��ͪ1�_�d�tt!"V7�n������LZ�(����r����p����6�JY���U
����s����;[�E8D&a��h@-
��$� Xt�im���F�*&�41���̭M��ؒ]����WL�f�}��9anIH���Qs1���KtO��ll���O���X߬�	�{�ŋ����œ�?o'�>PK�(2��PK-�q,-�[Content_Types].xmlPK-��z��Iv_rels/.relsPK-�|wؑ��docProps/app.xmlPK-qBj�qdocProps/core.xmlPK-p��&x��xl/sharedStrings.xmlPK-�1X@C�
xl/styles.xmlPK-	���xl/workbook.xmlPK-�(2���xl/_rels/workbook.xml.relsPK��
+
+
+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/talk.html b/src/main/resources/static/docs/talk.html new file mode 100644 index 00000000..895ace5a --- /dev/null +++ b/src/main/resources/static/docs/talk.html @@ -0,0 +1,1956 @@ + + + + + + + +대담 영상 API + + + + + +
+
+

대담 영상 API

+
+
+
+

대담 영상 생성 (POST /talks)

+
+
+
+

HTTP request

+
+
+
POST /talks HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Content-Length: 361
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "title" : "제목",
+  "youtubeId" : "유튜브 고유ID",
+  "year" : 2024,
+  "talkerBelonging" : "대담자 소속",
+  "talkerName" : "대담자 성명",
+  "quiz" : [ {
+    "question" : "질문1",
+    "answer" : 0,
+    "options" : [ "선지1", "선지2" ]
+  }, {
+    "question" : "질문2",
+    "answer" : 0,
+    "options" : [ "선지1", "선지2" ]
+  } ]
+}
+
+
+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

대담 영상 제목

youtubeId

String

true

유튜브 영상의 고유 ID

year

Number

true

대담 영상 연도

talkerBelonging

String

true

대담자의 소속된 직장/단체

talkerName

String

true

대담자의 성명

quiz

Array

false

퀴즈 데이터, 없는경우 null

quiz[].question

String

false

퀴즈 1개의 질문

quiz[].answer

Number

false

퀴즈 1개의 정답선지 인덱스

quiz[].options

Array

false

퀴즈 1개의 정답선지 리스트

+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 465
+
+{
+  "id" : 1,
+  "title" : "제목",
+  "youtubeId" : "유튜브 고유ID",
+  "year" : 2024,
+  "talkerBelonging" : "대담자 소속",
+  "talkerName" : "대담자 성명",
+  "quiz" : [ {
+    "question" : "질문1",
+    "answer" : 0,
+    "options" : [ "선지1", "선지2" ]
+  }, {
+    "question" : "질문2",
+    "answer" : 0,
+    "options" : [ "선지1", "선지2" ]
+  } ],
+  "createdAt" : "2024-11-28T19:49:22.817542",
+  "updatedAt" : "2024-11-28T19:49:22.817543"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

대담 영상 ID

title

String

true

대담 영상 제목

youtubeId

String

true

유튜브 영상의 고유 ID

year

Number

true

대담 영상 연도

talkerBelonging

String

true

대담자의 소속된 직장/단체

talkerName

String

true

대담자의 성명

quiz

Array

false

퀴즈 데이터, 없는경우 null

quiz[].question

String

false

퀴즈 1개의 질문

quiz[].answer

Number

false

퀴즈 1개의 정답선지 인덱스

quiz[].options

Array

false

퀴즈 1개의 정답선지 리스트

createdAt

String

true

대담 영상 생성일

updatedAt

String

true

대담 영상 수정일

+
+
+
+
+
+

대담 영상 리스트 조회 (GET /talks)

+
+
+
+

HTTP request

+
+
+
GET /talks HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: optional_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Query parameters

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterRequiredDescription

year

false

찾고자 하는 대담 영상의 연도

title

false

찾고자 하는 대담 영상의 제목 일부

page

false

페이지 번호 [default: 0]

size

false

페이지 크기 [default: 10]

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 999
+
+{
+  "totalPages" : 1,
+  "totalElements" : 1,
+  "first" : true,
+  "last" : true,
+  "size" : 10,
+  "content" : [ {
+    "id" : 1,
+    "title" : "제목",
+    "youtubeId" : "유튜브 고유ID",
+    "year" : 2024,
+    "talkerBelonging" : "대담자 소속",
+    "talkerName" : "대담자 성명",
+    "favorite" : true,
+    "quiz" : [ {
+      "question" : "질문1",
+      "answer" : 0,
+      "options" : [ "선지1", "선지2" ]
+    }, {
+      "question" : "질문2",
+      "answer" : 0,
+      "options" : [ "선지1", "선지2" ]
+    } ],
+    "createdAt" : "2024-11-28T19:49:22.824681",
+    "updatedAt" : "2024-11-28T19:49:22.824682"
+  } ],
+  "number" : 0,
+  "sort" : {
+    "empty" : true,
+    "sorted" : false,
+    "unsorted" : true
+  },
+  "numberOfElements" : 1,
+  "pageable" : {
+    "pageNumber" : 0,
+    "pageSize" : 10,
+    "sort" : {
+      "empty" : true,
+      "sorted" : false,
+      "unsorted" : true
+    },
+    "offset" : 0,
+    "paged" : true,
+    "unpaged" : false
+  },
+  "empty" : false
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

pageable

Object

true

페이지 정보

pageable.pageNumber

Number

true

현재 페이지 번호

pageable.pageSize

Number

true

페이지 당 요소 수

pageable.sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

pageable.sort.sorted

Boolean

true

정렬된 상태인지 여부

pageable.sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

pageable.offset

Number

true

오프셋

pageable.paged

Boolean

true

페이징된 여부

pageable.unpaged

Boolean

true

페이징되지 않은 여부

totalElements

Number

true

전체 요소 수

totalPages

Number

true

전체 페이지 수

size

Number

true

페이지 당 요소 수

number

Number

true

현재 페이지 번호

numberOfElements

Number

true

현재 페이지 요소 수

first

Boolean

true

첫 페이지 여부

last

Boolean

true

마지막 페이지 여부

sort.empty

Boolean

true

정렬 정보가 비어있는지 여부

sort.unsorted

Boolean

true

정렬되지 않은 상태인지 여부

sort.sorted

Boolean

true

정렬된 상태인지 여부

empty

Boolean

true

비어있는 페이지 여부

content[].id

Number

true

대담 영상 ID

content[].title

String

true

대담 영상 제목

content[].youtubeId

String

true

유튜브 영상의 고유 ID

content[].year

Number

true

대담 영상 연도

content[].talkerBelonging

String

true

대담자의 소속된 직장/단체

content[].talkerName

String

true

대담자의 성명

content[].favorite

Boolean

true

관심한 대담영상의 여부

content[].quiz

Array

false

퀴즈 데이터, 없는경우 null

content[].quiz[].question

String

false

퀴즈 1개의 질문

content[].quiz[].answer

Number

false

퀴즈 1개의 정답선지 인덱스

content[].quiz[].options

Array

false

퀴즈 1개의 정답선지 리스트

content[].createdAt

String

true

대담 영상 생성일

content[].updatedAt

String

true

대담 영상 수정일

+
+
+
+
+
+

대담 영상 단건 조회 (GET /talks/{talkId})

+
+
+
+

HTTP request

+
+
+
GET /talks/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: optional_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /talks/{talkId}
ParameterDescription

talkId

조회할 대담 영상의 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 486
+
+{
+  "id" : 1,
+  "title" : "제목",
+  "youtubeId" : "유튜브 고유ID",
+  "year" : 2024,
+  "talkerBelonging" : "대담자 소속",
+  "talkerName" : "대담자 성명",
+  "favorite" : true,
+  "quiz" : [ {
+    "question" : "질문1",
+    "answer" : 0,
+    "options" : [ "선지1", "선지2" ]
+  }, {
+    "question" : "질문2",
+    "answer" : 0,
+    "options" : [ "선지1", "선지2" ]
+  } ],
+  "createdAt" : "2024-11-28T19:49:22.809931",
+  "updatedAt" : "2024-11-28T19:49:22.809933"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

대담 영상 ID

title

String

true

대담 영상 제목

youtubeId

String

true

유튜브 영상의 고유 ID

year

Number

true

대담 영상 연도

talkerBelonging

String

true

대담자의 소속된 직장/단체

talkerName

String

true

대담자의 성명

favorite

Boolean

true

관심한 대담영상의 여부

quiz

Array

false

퀴즈 데이터, 없는경우 null

quiz[].question

String

false

퀴즈 1개의 질문

quiz[].answer

Number

false

퀴즈 1개의 정답선지 인덱스

quiz[].options

Array

false

퀴즈 1개의 정답선지 리스트

createdAt

String

true

대담 영상 생성일

updatedAt

String

true

대담 영상 수정일

+
+
+
+
+
+

대담 영상 수정 (PUT /talks/{talkId})

+
+
+
+

HTTP request

+
+
+
PUT /talks/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: admin_access_token
+Content-Length: 461
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "title" : "수정한 제목",
+  "youtubeId" : "수정한 유튜브 고유ID",
+  "year" : 2024,
+  "talkerBelonging" : "수정한 대담자 소속",
+  "talkerName" : "수정한 대담자 성명",
+  "quiz" : [ {
+    "question" : "수정한 질문1",
+    "answer" : 0,
+    "options" : [ "수정한 선지1", "수정한 선지2" ]
+  }, {
+    "question" : "수정한 질문2",
+    "answer" : 0,
+    "options" : [ "수정한 선지1", "수정한 선지2" ]
+  } ]
+}
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /talks/{talkId}
ParameterDescription

talkId

수정할 대담 영상의 ID

+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

title

String

true

대담 영상 제목

youtubeId

String

true

유튜브 영상의 고유 ID

year

Number

true

대담 영상 연도

talkerBelonging

String

true

대담자의 소속된 직장/단체

talkerName

String

true

대담자의 성명

quiz

Array

false

퀴즈 데이터, 없는경우 null

quiz[].question

String

false

퀴즈 1개의 질문

quiz[].answer

Number

false

퀴즈 1개의 정답선지 인덱스

quiz[].options

Array

false

퀴즈 1개의 정답선지 리스트

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 565
+
+{
+  "id" : 1,
+  "title" : "수정한 제목",
+  "youtubeId" : "수정한 유튜브 고유ID",
+  "year" : 2024,
+  "talkerBelonging" : "수정한 대담자 소속",
+  "talkerName" : "수정한 대담자 성명",
+  "quiz" : [ {
+    "question" : "수정한 질문1",
+    "answer" : 0,
+    "options" : [ "수정한 선지1", "수정한 선지2" ]
+  }, {
+    "question" : "수정한 질문2",
+    "answer" : 0,
+    "options" : [ "수정한 선지1", "수정한 선지2" ]
+  } ],
+  "createdAt" : "2024-11-28T19:49:22.777287",
+  "updatedAt" : "2024-11-28T19:49:22.777288"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

대담 영상 ID

title

String

true

대담 영상 제목

youtubeId

String

true

유튜브 영상의 고유 ID

year

Number

true

대담 영상 연도

talkerBelonging

String

true

대담자의 소속된 직장/단체

talkerName

String

true

대담자의 성명

quiz

Array

false

퀴즈 데이터, 없는경우 null

quiz[].question

String

false

퀴즈 1개의 질문

quiz[].answer

Number

false

퀴즈 1개의 정답선지 인덱스

quiz[].options

Array

false

퀴즈 1개의 정답선지 리스트

createdAt

String

true

대담 영상 생성일

updatedAt

String

true

대담 영상 수정일

+
+
+
+
+
+

대담 영상 삭제 (DELETE /talks/{talkId})

+
+
+
+

HTTP request

+
+
+
DELETE /talks/1 HTTP/1.1
+Authorization: admin_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /talks/{talkId}
ParameterDescription

talkId

삭제할 대담 영상의 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+
+

대담 영상의 퀴즈 조회 (GET /talks/{talkId}/quiz)

+
+
+
+

HTTP request

+
+
+
GET /talks/1/quiz HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Host: localhost:8080
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /talks/{talkId}/quiz
ParameterDescription

talkId

퀴즈를 가져올 대담 영상의 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 205
+
+{
+  "quiz" : [ {
+    "question" : "질문1",
+    "answer" : 0,
+    "options" : [ "선지1", "선지2" ]
+  }, {
+    "question" : "질문2",
+    "answer" : 0,
+    "options" : [ "선지1", "선지2" ]
+  } ]
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

quiz

Array

false

퀴즈 데이터, 없는경우 null

quiz[].question

String

false

퀴즈 1개의 질문

quiz[].answer

Number

false

퀴즈 1개의 정답선지 인덱스

quiz[].options

Array

false

퀴즈 1개의 정답선지 리스트

+
+
+
+
+
+

대담 영상의 퀴즈 결과 제출 (POST /talks/{talkId}/quiz)

+
+
+
+

HTTP request

+
+
+
POST /talks/1/quiz HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: access_token
+Content-Length: 47
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "result" : {
+    "0" : 0,
+    "1" : 1
+  }
+}
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /talks/{talkId}/quiz
ParameterDescription

talkId

퀴즈를 제출할 대담 영상의 ID

+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

result

Object

true

퀴즈를 푼 결과

result.*

Number

true

퀴즈 각 문제별 정답 인덱스, key는 문제 번호

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 40
+
+{
+  "success" : true,
+  "tryCount" : 1
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

success

Boolean

true

퀴즈 성공 여부

tryCount

Number

true

퀴즈 시도 횟수

+
+
+
+
+
+

대담 영상의 관심 등록 (POST /talks/{talkId}/favorite)

+
+
+
+

HTTP request

+
+
+
POST /talks/1/favorite HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /talks/{talkId}/favorite
ParameterDescription

talkId

관심 목록에 추가할 대담 영상의 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+
+

대담 영상의 관심 삭제 (DELETE /talks/{talkId}/favorite)

+
+
+
+

HTTP request

+
+
+
DELETE /talks/1/favorite HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /talks/{talkId}/favorite
ParameterDescription

talkId

관심 목록에서 삭제할 대담 영상의 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+
+

유저의 퀴즈 제출 기록 조회 (GET /talks/{talkId/quiz/submit)

+
+
+
+

HTTP request

+
+
+
GET /talks/1/quiz/submit HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /talks/{talkId}/quiz/submit
ParameterDescription

talkId

퀴즈가 연결된 대담 영상의 ID

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+Content-Length: 41
+
+{
+  "success" : false,
+  "tryCount" : 2
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

tryCount

Number

true

시도한 횟수

success

Boolean

true

퀴즈 성공 여부

+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/user.html b/src/main/resources/static/docs/user.html new file mode 100644 index 00000000..96730221 --- /dev/null +++ b/src/main/resources/static/docs/user.html @@ -0,0 +1,1525 @@ + + + + + + + +유저 API + + + + + +
+
+

유저 API

+
+
+
+

로그인 유저 기본 정보 조회 (GET /users/me)

+
+
+
+

HTTP request

+
+
+
GET /users/me HTTP/1.1
+Authorization: user_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 323
+
+{
+  "id" : 1,
+  "name" : "이름",
+  "phone" : "010-1234-5678",
+  "email" : "student@g.skku.edu",
+  "userType" : "STUDENT",
+  "division" : null,
+  "position" : null,
+  "studentNumber" : "2000123456",
+  "departmentName" : "학과",
+  "createdAt" : "2024-11-28T19:49:21.977643",
+  "updatedAt" : "2024-11-28T19:49:21.977644"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

사용자 ID

name

String

true

사용자 이름

phone

String

true

사용자 전화번호

email

String

true

사용자 이메일

userType

String

true

사용자 유형

division

String

false

소속

position

String

false

직책

studentNumber

String

false

학번

departmentName

String

false

학과 이름

createdAt

String

true

생성일

updatedAt

String

true

수정일

+
+
+
+
+
+

로그인 유저 기본 정보 수정 (PUT /users/me)

+
+
+
+

HTTP request

+
+
+
PUT /users/me HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: user_access_token
+Content-Length: 195
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+{
+  "name" : "이름",
+  "phoneNumber" : "010-1234-5678",
+  "email" : "student@g.skku.edu",
+  "division" : null,
+  "position" : null,
+  "studentNumber" : "2000123456",
+  "department" : "학과"
+}
+
+
+
+
+

Request fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

name

String

true

이름

phoneNumber

String

true

전화번호

email

String

true

이메일

division

String

false

소속

position

String

false

직책

studentNumber

String

false

학번

department

String

false

학과

+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 323
+
+{
+  "id" : 1,
+  "name" : "이름",
+  "phone" : "010-1234-5678",
+  "email" : "student@g.skku.edu",
+  "userType" : "STUDENT",
+  "division" : null,
+  "position" : null,
+  "studentNumber" : "2000123456",
+  "departmentName" : "학과",
+  "createdAt" : "2024-11-28T19:49:22.000494",
+  "updatedAt" : "2024-11-28T19:49:22.000495"
+}
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

id

Number

true

사용자 ID

name

String

true

사용자 이름

phone

String

true

사용자 전화번호

email

String

true

사용자 이메일

userType

String

true

사용자 유형

division

String

false

소속

position

String

false

직책

studentNumber

String

false

학번

departmentName

String

false

학과 이름

createdAt

String

true

생성일

updatedAt

String

true

수정일

+
+
+
+
+
+

유저 탈퇴 (DELETE /users/me)

+
+
+
+

HTTP request

+
+
+
DELETE /users/me HTTP/1.1
+Authorization: user_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
+
+
+
+
+

유저 관심 프로젝트 리스트 조회 (GET /users/favorites/projects)

+
+
+
+

HTTP request

+
+
+
GET /users/favorites/projects HTTP/1.1
+Authorization: user_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 1204
+
+[ {
+  "id" : 1,
+  "thumbnailInfo" : {
+    "id" : 1,
+    "uuid" : "썸네일 uuid 1",
+    "name" : "썸네일 파일 이름 1",
+    "mimeType" : "썸네일 mime 타입 1"
+  },
+  "projectName" : "프로젝트 이름 1",
+  "teamName" : "팀 이름 1",
+  "studentNames" : [ "학생 이름 1", "학생 이름 2" ],
+  "professorNames" : [ "교수 이름 1" ],
+  "projectType" : "STARTUP",
+  "projectCategory" : "BIG_DATA_ANALYSIS",
+  "awardStatus" : "FIRST",
+  "year" : 2023,
+  "likeCount" : 100,
+  "like" : false,
+  "bookMark" : false,
+  "url" : "프로젝트 URL",
+  "description" : "프로젝트 설명"
+}, {
+  "id" : 2,
+  "thumbnailInfo" : {
+    "id" : 2,
+    "uuid" : "썸네일 uuid 2",
+    "name" : "썸네일 파일 이름 2",
+    "mimeType" : "썸네일 mime 타입 2"
+  },
+  "projectName" : "프로젝트 이름 2",
+  "teamName" : "팀 이름 2",
+  "studentNames" : [ "학생 이름 3", "학생 이름 4" ],
+  "professorNames" : [ "교수 이름 2" ],
+  "projectType" : "LAB",
+  "projectCategory" : "AI_MACHINE_LEARNING",
+  "awardStatus" : "SECOND",
+  "year" : 2023,
+  "likeCount" : 100,
+  "like" : false,
+  "bookMark" : true,
+  "url" : "프로젝트 URL",
+  "description" : "프로젝트 설명"
+} ]
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

[].id

Number

true

프로젝트 ID

[].thumbnailInfo

Object

true

썸네일 정보

[].thumbnailInfo.id

Number

true

썸네일 ID

[].thumbnailInfo.uuid

String

true

썸네일 UUID

[].thumbnailInfo.name

String

true

썸네일 파일 이름

[].thumbnailInfo.mimeType

String

true

썸네일 MIME 타입

[].projectName

String

true

프로젝트 이름

[].teamName

String

true

팀 이름

[].studentNames[]

Array

true

학생 이름

[].professorNames[]

Array

true

교수 이름

[].projectType

String

true

프로젝트 타입: RESEARCH_AND_BUSINESS_FOUNDATION, LAB, STARTUP, CLUB

[].projectCategory

String

true

프로젝트 카테고리: COMPUTER_VISION, SYSTEM_NETWORK, WEB_APPLICATION, SECURITY_SOFTWARE_ENGINEERING, NATURAL_LANGUAGE_PROCESSING, BIG_DATA_ANALYSIS, AI_MACHINE_LEARNING, INTERACTION_AUGMENTED_REALITY

[].awardStatus

String

true

수상 여부: NONE, FIRST, SECOND, THIRD, FOURTH, FIFTH

[].year

Number

true

프로젝트 년도

[].likeCount

Number

true

좋아요 수

[].like

Boolean

true

좋아요 여부

[].bookMark

Boolean

true

북마크 여부

[].url

String

true

프로젝트 URL

[].description

String

true

프로젝트 설명

+
+
+
+
+
+

유저 관심 대담영상 리스트 조회 (GET /users/favorites/talks)

+
+
+
+

HTTP request

+
+
+
GET /users/favorites/talks HTTP/1.1
+Authorization: user_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 982
+
+[ {
+  "id" : 1,
+  "title" : "제목1",
+  "youtubeId" : "유튜브 고유ID",
+  "year" : 2024,
+  "talkerBelonging" : "대담자 소속1",
+  "talkerName" : "대담자 성명1",
+  "favorite" : true,
+  "quiz" : [ {
+    "question" : "질문1",
+    "answer" : 0,
+    "options" : [ "선지1", "선지2" ]
+  }, {
+    "question" : "질문2",
+    "answer" : 0,
+    "options" : [ "선지1", "선지2" ]
+  } ],
+  "createdAt" : "2024-11-28T19:49:21.961699",
+  "updatedAt" : "2024-11-28T19:49:21.9617"
+}, {
+  "id" : 2,
+  "title" : "제목2",
+  "youtubeId" : "유튜브 고유ID",
+  "year" : 2024,
+  "talkerBelonging" : "대담자 소속2",
+  "talkerName" : "대담자 성명2",
+  "favorite" : true,
+  "quiz" : [ {
+    "question" : "질문1",
+    "answer" : 0,
+    "options" : [ "선지1", "선지2" ]
+  }, {
+    "question" : "질문2",
+    "answer" : 0,
+    "options" : [ "선지1", "선지2" ]
+  } ],
+  "createdAt" : "2024-11-28T19:49:21.961706",
+  "updatedAt" : "2024-11-28T19:49:21.961707"
+} ]
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

[].id

Number

true

대담 영상 ID

[].title

String

true

대담 영상 제목

[].youtubeId

String

true

유튜브 영상의 고유 ID

[].year

Number

true

대담 영상 연도

[].talkerBelonging

String

true

대담자의 소속된 직장/단체

[].talkerName

String

true

대담자의 성명

[].favorite

Boolean

true

관심한 대담영상의 여부

[].quiz

Array

false

퀴즈 데이터, 없는경우 null

[].quiz[].question

String

false

퀴즈 1개의 질문

[].quiz[].answer

Number

false

퀴즈 1개의 정답선지 인덱스

[].quiz[].options

Array

false

퀴즈 1개의 정답선지 리스트

[].createdAt

String

true

대담 영상 생성일

[].updatedAt

String

true

대담 영상 수정일

+
+
+
+
+
+

유저 관심 잡페어인터뷰영상 리스트 조회 (GET /users/favorites/jobInterviews)

+
+
+
+

HTTP request

+
+
+
GET /users/favorites/jobInterviews HTTP/1.1
+Authorization: user_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 690
+
+[ {
+  "id" : 1,
+  "title" : "잡페어 인터뷰의 제목1",
+  "youtubeId" : "유튜브 고유 ID1",
+  "year" : 2023,
+  "talkerBelonging" : "대담자의 소속1",
+  "talkerName" : "대담자의 성명1",
+  "favorite" : false,
+  "category" : "INTERN",
+  "createdAt" : "2024-11-28T19:49:21.953649",
+  "updatedAt" : "2024-11-28T19:49:21.95365"
+}, {
+  "id" : 2,
+  "title" : "잡페어 인터뷰의 제목2",
+  "youtubeId" : "유튜브 고유 ID2",
+  "year" : 2024,
+  "talkerBelonging" : "대담자의 소속2",
+  "talkerName" : "대담자의 성명2",
+  "favorite" : true,
+  "category" : "INTERN",
+  "createdAt" : "2024-11-28T19:49:21.953664",
+  "updatedAt" : "2024-11-28T19:49:21.953665"
+} ]
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

[].id

Number

true

잡페어 인터뷰 ID

[].title

String

true

잡페어 인터뷰 제목

[].youtubeId

String

true

유튜브 영상의 고유 ID

[].year

Number

true

잡페어 인터뷰 연도

[].talkerBelonging

String

true

잡페어 인터뷰 대담자의 소속

[].talkerName

String

true

잡페어 인터뷰 대담자의 성명

[].favorite

Boolean

true

관심에 추가한 잡페어 인터뷰 여부

[].category

String

true

잡페어 인터뷰 카테고리: SENIOR, INTERN

[].createdAt

String

true

잡페어 인터뷰 생성일

[].updatedAt

String

true

잡페어 인터뷰 수정일

+
+
+
+
+
+

유저 문의 리스트 조회 (GET /users/inquiries)

+
+
+
+

HTTP request

+
+
+
GET /users/inquiries HTTP/1.1
+Authorization: user_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 257
+
+[ {
+  "id" : 1,
+  "title" : "Title 1",
+  "projectId" : 1,
+  "createdDate" : "2024-11-28T19:49:21.971752",
+  "hasReply" : true
+}, {
+  "id" : 2,
+  "title" : "Title 2",
+  "projectId" : 2,
+  "createdDate" : "2024-11-28T19:49:21.971758",
+  "hasReply" : false
+} ]
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

[].id

Number

true

문의 ID

[].title

String

true

문의 제목

[].projectId

Number

true

프로젝트 ID

[].createdDate

String

true

문의 생성일

[].hasReply

Boolean

true

답변 여부

+
+
+
+
+
+

유저 과제 제안 리스트 조회 (GET /users/proposals)

+
+
+
+

HTTP request

+
+
+
GET /users/proposals HTTP/1.1
+Authorization: user_access_token
+Host: localhost:8080
+Cookie: refresh-token=refresh_token
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 219
+
+[ {
+  "id" : 1,
+  "title" : "Title 1",
+  "createdDate" : "2024-11-28T19:49:21.983617",
+  "hasReply" : true
+}, {
+  "id" : 2,
+  "title" : "Title 2",
+  "createdDate" : "2024-11-28T19:49:21.983623",
+  "hasReply" : false
+} ]
+
+
+
+
+

Response fields

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription

[].id

Number

true

과제 제안 ID

[].title

String

true

프로젝트명

[].createdDate

String

true

과제 제안 생성일

[].hasReply

Boolean

true

답변 여부

+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/templates/email.html b/src/main/resources/templates/email.html new file mode 100644 index 00000000..9426e378 --- /dev/null +++ b/src/main/resources/templates/email.html @@ -0,0 +1,94 @@ + + + + + + S-TOP 안내 이메일 + + + + + + diff --git a/src/test/java/com/scg/stop/aiHub/controller/AiHubControllerTest.java b/src/test/java/com/scg/stop/aiHub/controller/AiHubControllerTest.java new file mode 100644 index 00000000..356de4d6 --- /dev/null +++ b/src/test/java/com/scg/stop/aiHub/controller/AiHubControllerTest.java @@ -0,0 +1,242 @@ +package com.scg.stop.aiHub.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.scg.stop.aihub.controller.AiHubController; +import com.scg.stop.aihub.dto.request.AiHubDatasetRequest; +import com.scg.stop.aihub.dto.request.AiHubModelRequest; +import com.scg.stop.aihub.dto.response.AiHubDatasetResponse; +import com.scg.stop.aihub.dto.response.AiHubModelResponse; +import com.scg.stop.aihub.service.AiHubService; +import com.scg.stop.configuration.AbstractControllerTest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.web.servlet.ResultActions; + +import java.util.Arrays; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(AiHubController.class) +@MockBean(JpaMetamodelMappingContext.class) +@AutoConfigureRestDocs +class AiHubControllerTest extends AbstractControllerTest { + + @MockBean + private AiHubService aiHubService; + + @Autowired + private ObjectMapper objectMapper; + + @Test + @DisplayName("AI Hub 모델 리스트를 조회할 수 있다.") + void getAiHubModels() throws Exception { + + // given + + AiHubModelRequest aiHubModelRequest = new AiHubModelRequest( + "title", + Arrays.asList("학습 모델 1"), + Arrays.asList("주제 1"), + Arrays.asList(2024), + "담당 교수 1", + Arrays.asList("학생 1") + ); + + AiHubModelResponse aiHubModelResponse1 = new AiHubModelResponse( + "page", + "노션 object 아이디", + "커버 이미지 link", + "title", Arrays.asList("학습 모델 1"), + Arrays.asList("주제 1"), + Arrays.asList(2024), + "담당 교수 1", + Arrays.asList("학생 1", "학생 2"), + "노션 redirect url"); + + AiHubModelResponse aiHubModelResponse2 = new AiHubModelResponse( + "page", + "노션 object 아이디", + "커버 이미지 link", + "title", Arrays.asList("학습 모델 1"), + Arrays.asList("주제 1", "주제 2"), + Arrays.asList(2024), + "담당 교수 1", + Arrays.asList("학생 1", "학생 2", "학생 3"), + "노션 redirect url"); + Page page = new PageImpl<>(List.of(aiHubModelResponse1, aiHubModelResponse2), PageRequest.of(0, 10), 2); + + when(aiHubService.getAiHubModels(any(AiHubModelRequest.class), any(Pageable.class))).thenReturn(page); + + // when + ResultActions result = mockMvc.perform( + post("/aihub/models") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(aiHubModelRequest)) + ); + + // then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + queryParameters( + parameterWithName("page").description("페이지 번호 [default: 0]").optional(), + parameterWithName("size").description("페이지 크기 [default: 10]").optional() + ), + requestFields( + fieldWithPath("title").type(JsonFieldType.STRING).description("AI 모델 제목"), + fieldWithPath("learningModels").type(JsonFieldType.ARRAY).description("학습 모델"), + fieldWithPath("topics").type(JsonFieldType.ARRAY).description("주제 분류"), + fieldWithPath("developmentYears").type(JsonFieldType.ARRAY).description("개발 년도"), + fieldWithPath("professor").type(JsonFieldType.STRING).description("담당 교수"), + fieldWithPath("participants").type(JsonFieldType.ARRAY).description("참여 학생") + ), + responseFields( + fieldWithPath("content[].object").type(JsonFieldType.STRING).description("object 종류"), + fieldWithPath("content[].id").type(JsonFieldType.STRING).description("노션 object 아이디"), + fieldWithPath("content[].cover").type(JsonFieldType.STRING).description("커버 이미지 link"), + fieldWithPath("content[].title").type(JsonFieldType.STRING).description("AI 모델 제목"), + fieldWithPath("content[].learningModels").type(JsonFieldType.ARRAY).description("학습 모델"), + fieldWithPath("content[].topics").type(JsonFieldType.ARRAY).description("주제 분류"), + fieldWithPath("content[].developmentYears").type(JsonFieldType.ARRAY).description("개발 년도"), + fieldWithPath("content[].professor").type(JsonFieldType.STRING).description("담당 교수"), + fieldWithPath("content[].participants").type(JsonFieldType.ARRAY).description("참여 학생"), + fieldWithPath("content[].url").type(JsonFieldType.STRING).description("노션 redirect url"), + fieldWithPath("pageable").type(JsonFieldType.OBJECT).description("페이지 정보"), + fieldWithPath("pageable.pageNumber").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("pageable.pageSize").type(JsonFieldType.NUMBER).description("페이지 당 요소 수"), + fieldWithPath("pageable.sort.empty").type(JsonFieldType.BOOLEAN).description("정렬 정보가 비어있는지 여부"), + fieldWithPath("pageable.sort.sorted").type(JsonFieldType.BOOLEAN).description("정렬된 상태인지 여부"), + fieldWithPath("pageable.sort.unsorted").type(JsonFieldType.BOOLEAN).description("정렬되지 않은 상태인지 여부"), + fieldWithPath("pageable.offset").type(JsonFieldType.NUMBER).description("오프셋"), + fieldWithPath("pageable.paged").type(JsonFieldType.BOOLEAN).description("페이징된 여부"), + fieldWithPath("pageable.unpaged").type(JsonFieldType.BOOLEAN).description("페이징되지 않은 여부"), + fieldWithPath("totalElements").type(JsonFieldType.NUMBER).description("전체 요소 수"), + fieldWithPath("totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), + fieldWithPath("size").type(JsonFieldType.NUMBER).description("페이지 당 요소 수"), + fieldWithPath("number").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("numberOfElements").type(JsonFieldType.NUMBER).description("현재 페이지 요소 수"), + fieldWithPath("first").type(JsonFieldType.BOOLEAN).description("첫 페이지 여부"), + fieldWithPath("last").type(JsonFieldType.BOOLEAN).description("마지막 페이지 여부"), + fieldWithPath("sort.empty").type(JsonFieldType.BOOLEAN).description("정렬 정보가 비어있는지 여부"), + fieldWithPath("sort.unsorted").type(JsonFieldType.BOOLEAN).description("정렬되지 않은 상태인지 여부"), + fieldWithPath("sort.sorted").type(JsonFieldType.BOOLEAN).description("정렬된 상태인지 여부"), + fieldWithPath("empty").type(JsonFieldType.BOOLEAN).description("비어있는 페이지 여부") + ) + )); + } + + @Test + @DisplayName("AI Hub 데이터셋 리스트를 조회할 수 있다.") + void getAiHubDatasets() throws Exception { + + // given + + AiHubDatasetRequest aiHubDatasetRequest = new AiHubDatasetRequest( + "title", + Arrays.asList("주제 1"), + Arrays.asList("데이터 유형 1"), + Arrays.asList(2024), + "담당 교수 1", + Arrays.asList("학생 1") + ); + + AiHubDatasetResponse aiHubDatasetResponse1 = new AiHubDatasetResponse( + "page", + "노션 object 아이디", + "커버 이미지 link", + "title", Arrays.asList("주제 1"), + Arrays.asList("데이터 유형 1"), + Arrays.asList(2024), + "담당 교수 1", + Arrays.asList("학생 1", "학생 2"), + "노션 redirect url"); + + AiHubDatasetResponse aiHubDatasetResponse2 = new AiHubDatasetResponse( + "page", + "노션 object 아이디", + "커버 이미지 link", + "title", Arrays.asList("주제 1", "주제 2"), + Arrays.asList("데이터 유형 1"), + Arrays.asList(2024), + "담당 교수 1", + Arrays.asList("학생 1", "학생 2", "학생 3"), + "노션 redirect url"); + Page page = new PageImpl<>(List.of(aiHubDatasetResponse1, aiHubDatasetResponse2), PageRequest.of(0, 10), 2); + + when(aiHubService.getAiHubDatasets(any(AiHubDatasetRequest.class), any(Pageable.class))).thenReturn(page); + + // when + ResultActions result = mockMvc.perform( + post("/aihub/datasets") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(aiHubDatasetRequest)) + ); + + // then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + queryParameters( + parameterWithName("page").description("페이지 번호 [default: 0]").optional(), + parameterWithName("size").description("페이지 크기 [default: 10]").optional() + ), + requestFields( + fieldWithPath("title").type(JsonFieldType.STRING).description("AI 데이터셋 제목"), + fieldWithPath("dataTypes").type(JsonFieldType.ARRAY).description("데이터 유형"), + fieldWithPath("topics").type(JsonFieldType.ARRAY).description("주제 분류"), + fieldWithPath("developmentYears").type(JsonFieldType.ARRAY).description("구축 년도"), + fieldWithPath("professor").type(JsonFieldType.STRING).description("담당 교수"), + fieldWithPath("participants").type(JsonFieldType.ARRAY).description("참여 학생") + ), + responseFields( + fieldWithPath("content[].object").type(JsonFieldType.STRING).description("object 종류"), + fieldWithPath("content[].id").type(JsonFieldType.STRING).description("노션 object 아이디"), + fieldWithPath("content[].cover").type(JsonFieldType.STRING).description("커버 이미지 link"), + fieldWithPath("content[].title").type(JsonFieldType.STRING).description("AI 데이터셋 제목"), + fieldWithPath("content[].topics").type(JsonFieldType.ARRAY).description("주제 분류"), + fieldWithPath("content[].dataTypes").type(JsonFieldType.ARRAY).description("데이터 유형"), + fieldWithPath("content[].developmentYears").type(JsonFieldType.ARRAY).description("구축 년도"), + fieldWithPath("content[].professor").type(JsonFieldType.STRING).description("담당 교수"), + fieldWithPath("content[].participants").type(JsonFieldType.ARRAY).description("참여 학생"), + fieldWithPath("content[].url").type(JsonFieldType.STRING).description("노션 redirect url"), + fieldWithPath("pageable").type(JsonFieldType.OBJECT).description("페이지 정보"), + fieldWithPath("pageable.pageNumber").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("pageable.pageSize").type(JsonFieldType.NUMBER).description("페이지 당 요소 수"), + fieldWithPath("pageable.sort.empty").type(JsonFieldType.BOOLEAN).description("정렬 정보가 비어있는지 여부"), + fieldWithPath("pageable.sort.sorted").type(JsonFieldType.BOOLEAN).description("정렬된 상태인지 여부"), + fieldWithPath("pageable.sort.unsorted").type(JsonFieldType.BOOLEAN).description("정렬되지 않은 상태인지 여부"), + fieldWithPath("pageable.offset").type(JsonFieldType.NUMBER).description("오프셋"), + fieldWithPath("pageable.paged").type(JsonFieldType.BOOLEAN).description("페이징된 여부"), + fieldWithPath("pageable.unpaged").type(JsonFieldType.BOOLEAN).description("페이징되지 않은 여부"), + fieldWithPath("totalElements").type(JsonFieldType.NUMBER).description("전체 요소 수"), + fieldWithPath("totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), + fieldWithPath("size").type(JsonFieldType.NUMBER).description("페이지 당 요소 수"), + fieldWithPath("number").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("numberOfElements").type(JsonFieldType.NUMBER).description("현재 페이지 요소 수"), + fieldWithPath("first").type(JsonFieldType.BOOLEAN).description("첫 페이지 여부"), + fieldWithPath("last").type(JsonFieldType.BOOLEAN).description("마지막 페이지 여부"), + fieldWithPath("sort.empty").type(JsonFieldType.BOOLEAN).description("정렬 정보가 비어있는지 여부"), + fieldWithPath("sort.unsorted").type(JsonFieldType.BOOLEAN).description("정렬되지 않은 상태인지 여부"), + fieldWithPath("sort.sorted").type(JsonFieldType.BOOLEAN).description("정렬된 상태인지 여부"), + fieldWithPath("empty").type(JsonFieldType.BOOLEAN).description("비어있는 페이지 여부") + ) + )); + } +} + diff --git a/src/test/java/com/scg/stop/auth/controller/AuthControllerTest.java b/src/test/java/com/scg/stop/auth/controller/AuthControllerTest.java new file mode 100644 index 00000000..bc730f71 --- /dev/null +++ b/src/test/java/com/scg/stop/auth/controller/AuthControllerTest.java @@ -0,0 +1,180 @@ +package com.scg.stop.auth.controller; + + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; +import static org.springframework.restdocs.cookies.CookieDocumentation.requestCookies; +import static org.springframework.restdocs.cookies.CookieDocumentation.responseCookies; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.scg.stop.auth.JwtUtil; +import com.scg.stop.auth.config.AuthUserArgumentResolver; +import com.scg.stop.auth.domain.StudentInfoDto; +import com.scg.stop.auth.domain.UserToken; +import com.scg.stop.auth.domain.request.RegisterRequest; +import com.scg.stop.auth.domain.response.AccessTokenResponse; +import com.scg.stop.auth.domain.response.RegisterResponse; +import com.scg.stop.auth.service.AuthService; +import com.scg.stop.configuration.AbstractControllerTest; +import com.scg.stop.user.domain.Department; +import com.scg.stop.user.domain.User; +import com.scg.stop.user.domain.UserType; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseCookie; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.web.servlet.MvcResult; +@WebMvcTest(AuthController.class) +@AutoConfigureRestDocs +public class AuthControllerTest extends AbstractControllerTest { + + private static final String ACCESS_TOKEN = "access_token"; + private static final String REISSUED_ACCESS_TOKEN = "reissued_access_token"; + private static final String REFRESH_TOKEN = "refresh_token"; + private static final String accessCode = "codefromkakaologin"; + private static final long ONE_WEEK_SECONDS = 199999; + + @Autowired + ObjectMapper objectMapper; + + @MockBean + AuthService authService; + + @MockBean + JwtUtil jwtUtil; + + @Test + @DisplayName("카카오 소셜 로그인을 할 수 있다.") + void kakaoSocialLogin() throws Exception { + //given & when + + when(authService.login(any())) + .thenReturn(new UserToken(ACCESS_TOKEN, REFRESH_TOKEN)); + MvcResult mvcResult = mockMvc.perform(RestDocumentationRequestBuilders.get("/auth/login/kakao?code=codefromkakaologin")) + .andDo(restDocs.document( + queryParameters( + parameterWithName("code").description("카카오 인가코드") + ), + responseCookies( + cookieWithName("refresh-token").description("리프레시 토큰"), + cookieWithName("access-token").description("억세트 토큰") + ) + ) + ) + .andReturn(); + + assertThat(mvcResult.getResponse().getCookies().length).isEqualTo(2); + } + @Test + @DisplayName("회원가입을 통해 추가 정보를 입력할 수 있다.") + void register() throws Exception { + //given + StudentInfoDto studentInfoDto = new StudentInfoDto("소프트웨어학과", "2021123123"); + RegisterRequest request = new RegisterRequest("stop-user", "010-1234-1234", UserType.STUDENT, "email@gmail.com", + "ad", studentInfoDto, null, null); + User user = new User("1"); + user.register("stop-user","email@email.com","010-1234-1234",UserType.STUDENT,"signupsource"); + RegisterResponse registerResponse = RegisterResponse.from(user); + when(authService.finishRegister(any(), any())) + .thenReturn(registerResponse); + UserToken userToken = new UserToken(ACCESS_TOKEN, REFRESH_TOKEN); + + //when + mockMvc.perform(post("/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + requestFields( + fieldWithPath("name").type(STRING).description("유저 이름"), + fieldWithPath("phoneNumber").type(STRING).description("전화 번호"), + fieldWithPath("userType").type(STRING).description("회원 유형"), + fieldWithPath("email").type(STRING).description("이메일"), + fieldWithPath("signUpSource").type(STRING).description("가입 경로").optional(), + fieldWithPath("studentInfo.department").type(STRING).description("학과"), + fieldWithPath("studentInfo.studentNumber").type(STRING).description("학번"), + fieldWithPath("division").type(STRING).description("소속").optional(), + fieldWithPath("position").type(STRING).description("직책").optional() + ), + responseFields( + fieldWithPath("name").description("이름"), + fieldWithPath("email").description("이메일"), + fieldWithPath("phone").description("전화번호") + ) + )); + //then + + } + + @Test + @DisplayName("Access Token을 재발급받을 수 있다.") + void reissueToken() throws Exception { + //given + when(authService.reissueAccessToken(REFRESH_TOKEN, ACCESS_TOKEN)) + .thenReturn(REISSUED_ACCESS_TOKEN); + + //when + MvcResult mvcResult = mockMvc.perform(post("/auth/reissue") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN))) + .andExpect(status().isOk()) + .andReturn(); + + AccessTokenResponse accessTokenResponse = objectMapper.readValue( + mvcResult.getResponse().getContentAsString(), + AccessTokenResponse.class + ); + + //then + assertThat(accessTokenResponse.getAccessToken()).isEqualTo(REISSUED_ACCESS_TOKEN); + } + + @Test + @DisplayName("로그아웃할 수 있다.") + void logout() throws Exception { + //given + Mockito.doNothing().when(authService).logout(any()); + + //when + mockMvc.perform(post("/auth/logout") + .header(HttpHeaders.AUTHORIZATION, ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN))) + .andExpect(status().isNoContent()); + + //then + Mockito.verify(authService).logout(any()); + } +} diff --git a/src/test/java/com/scg/stop/auth/infrastructure/JwtUtilTest.java b/src/test/java/com/scg/stop/auth/infrastructure/JwtUtilTest.java new file mode 100644 index 00000000..929f4995 --- /dev/null +++ b/src/test/java/com/scg/stop/auth/infrastructure/JwtUtilTest.java @@ -0,0 +1,44 @@ +package com.scg.stop.auth.infrastructure; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.scg.stop.auth.JwtUtil; +import com.scg.stop.auth.domain.UserToken; +import com.scg.stop.user.domain.User; +import com.scg.stop.user.domain.UserType; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwt; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.ContextConfiguration; + +public class JwtUtilTest { + + private JwtUtil jwtUtil = new JwtUtil( + "your-test-secret-key-1234567890123456", + 60000L, + 120000L + ); + @Test + @DisplayName("jwt 페이로드에 유저타입이 포함되어있다.") + void createTokenShouldIncludeUserType() { + // Given: User 객체 생성 + User user = new User("social"); + user.register("name", "email","010", UserType.ADMIN, "source"); + // When: JWT 생성 + UserToken userToken = jwtUtil.createLoginToken("12345", user); + + // Then: Access Token에서 Claims 추출 + Jws claimsJws = jwtUtil.parseToken(userToken.getAccessToken()); + Claims claims = claimsJws.getBody(); + + // 검증: Subject와 userType 확인 + assertThat(claims.getSubject()).isEqualTo("12345"); + assertThat(claims.get("userType", String.class)).isEqualTo("ADMIN"); + } + +} diff --git a/src/test/java/com/scg/stop/configuration/AbstractControllerTest.java b/src/test/java/com/scg/stop/configuration/AbstractControllerTest.java new file mode 100644 index 00000000..a14e9566 --- /dev/null +++ b/src/test/java/com/scg/stop/configuration/AbstractControllerTest.java @@ -0,0 +1,53 @@ +package com.scg.stop.configuration; + +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; + +import com.scg.stop.auth.config.AuthUserArgumentResolver; +import com.scg.stop.auth.repository.RefreshTokenRepository; +import com.scg.stop.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.result.MockMvcResultHandlers; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.filter.CharacterEncodingFilter; + +@Import(RestDocsConfiguration.class) +@ExtendWith(RestDocumentationExtension.class) +public abstract class AbstractControllerTest { + + @Autowired + protected RestDocumentationResultHandler restDocs; + + @Autowired + protected MockMvc mockMvc; + + @MockBean + protected AuthUserArgumentResolver authUserArgumentResolver; + + @MockBean + protected RefreshTokenRepository refreshTokenRepository; + + @MockBean + protected UserRepository userRepository; + + @BeforeEach + void setUp( + final WebApplicationContext context, + final RestDocumentationContextProvider restDocumentation) { + this.mockMvc = MockMvcBuilders.webAppContextSetup(context) + .apply(documentationConfiguration(restDocumentation)) + .alwaysDo(MockMvcResultHandlers.print()) + .alwaysDo(restDocs) + .addFilters(new CharacterEncodingFilter("UTF-8", true)) + .build(); + } +} diff --git a/src/test/java/com/scg/stop/configuration/RestDocsConfiguration.java b/src/test/java/com/scg/stop/configuration/RestDocsConfiguration.java new file mode 100644 index 00000000..851d6ca7 --- /dev/null +++ b/src/test/java/com/scg/stop/configuration/RestDocsConfiguration.java @@ -0,0 +1,20 @@ +package com.scg.stop.configuration; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation; +import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; +import org.springframework.restdocs.operation.preprocess.Preprocessors; +import org.springframework.restdocs.snippet.Attributes.Attribute; + +@TestConfiguration +public class RestDocsConfiguration { + @Bean + public RestDocumentationResultHandler write() { + return MockMvcRestDocumentation.document( + "{class-name}/{method-name}", + Preprocessors.preprocessRequest(Preprocessors.prettyPrint()), + Preprocessors.preprocessResponse(Preprocessors.prettyPrint()) + ); + } +} diff --git a/src/test/java/com/scg/stop/event/controller/EventNoticeControllerTest.java b/src/test/java/com/scg/stop/event/controller/EventNoticeControllerTest.java new file mode 100644 index 00000000..9b2fd26f --- /dev/null +++ b/src/test/java/com/scg/stop/event/controller/EventNoticeControllerTest.java @@ -0,0 +1,320 @@ +package com.scg.stop.event.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.scg.stop.configuration.AbstractControllerTest; +import com.scg.stop.file.dto.response.FileResponse; +import com.scg.stop.event.dto.request.EventNoticeRequest; +import com.scg.stop.event.dto.response.EventNoticeListElementResponse; +import com.scg.stop.event.dto.response.EventNoticeResponse; +import com.scg.stop.event.service.EventNoticeService; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.http.HttpHeaders; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.web.servlet.ResultActions; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; +import static org.springframework.restdocs.cookies.CookieDocumentation.requestCookies; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(EventNoticeController.class) +@MockBean(JpaMetamodelMappingContext.class) +@AutoConfigureRestDocs +class EventNoticeControllerTest extends AbstractControllerTest { + + private static final String ACCESS_TOKEN = "admin_access_token"; + private static final String REFRESH_TOKEN = "refresh_token"; + @MockBean + private EventNoticeService eventNoticeService; + + @Autowired + private ObjectMapper objectMapper; + + @Test + @DisplayName("이벤트 공지 사항을 생성할 수 있다.") + void createEventNotice() throws Exception { + + // given + EventNoticeRequest request = new EventNoticeRequest("이벤트 공지 사항 제목", "이벤트 공지 사항 내용", true, List.of(1L, 2L, 3L)); + + List files = Arrays.asList( + new FileResponse(1L, "014eb8a0-d4a6-11ee-adac-117d766aca1d", "예시 첨부 파일 1.jpg", "image/jpeg", LocalDateTime.now(), LocalDateTime.now()), + new FileResponse(2L, "11a480c0-13fa-11ef-9047-570191b390ea", "예시 첨부 파일 2.jpg", "image/jpeg", LocalDateTime.now(), LocalDateTime.now()), + new FileResponse(3L, "1883fc70-cfb4-11ee-a387-e754bd392d45", "예시 첨부 파일 3.jpg", "image/jpeg", LocalDateTime.now(), LocalDateTime.now()) + ); + EventNoticeResponse response = new EventNoticeResponse(1L, "이벤트 공지 사항 제목", "이벤트 공지 사항 내용", 0, true, LocalDateTime.now(), LocalDateTime.now(), files); + + when(eventNoticeService.createEventNotice(any(EventNoticeRequest.class))).thenReturn(response); + + // when + ResultActions result = mockMvc.perform( + post("/eventNotices") + .contentType(APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + .content(objectMapper.writeValueAsString(request)) + ); + + // then + result.andExpect(status().isCreated()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + requestFields( + fieldWithPath("title").type(JsonFieldType.STRING).description("이벤트 공지 사항 제목"), + fieldWithPath("content").type(JsonFieldType.STRING).description("이벤트 공지 사항 내용"), + fieldWithPath("fixed").type(JsonFieldType.BOOLEAN).description("이벤트 공지 사항 고정 여부"), + fieldWithPath("fileIds").type(JsonFieldType.ARRAY).description("첨부 파일 ID 목록").optional() + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("이벤트 공지 사항 ID"), + fieldWithPath("title").type(JsonFieldType.STRING).description("이벤트 공지 사항 제목"), + fieldWithPath("content").type(JsonFieldType.STRING).description("이벤트 공지 사항 내용"), + fieldWithPath("hitCount").type(JsonFieldType.NUMBER).description("이벤트 공지 사항 조회수"), + fieldWithPath("fixed").type(JsonFieldType.BOOLEAN).description("이벤트 공지 사항 고정 여부"), + fieldWithPath("createdAt").type(JsonFieldType.STRING).description("이벤트 공지 사항 생성일"), + fieldWithPath("updatedAt").type(JsonFieldType.STRING).description("이벤트 공지 사항 수정일"), + fieldWithPath("files").type(JsonFieldType.ARRAY).description("첨부 파일 목록"), + fieldWithPath("files[].id").type(JsonFieldType.NUMBER).description("파일 ID"), + fieldWithPath("files[].uuid").type(JsonFieldType.STRING).description("파일 고유 식별자"), + fieldWithPath("files[].name").type(JsonFieldType.STRING).description("파일 이름"), + fieldWithPath("files[].mimeType").type(JsonFieldType.STRING).description("파일 MIME 타입"), + fieldWithPath("files[].createdAt").type(JsonFieldType.STRING).description("파일 생성 시간"), + fieldWithPath("files[].updatedAt").type(JsonFieldType.STRING).description("파일 수정 시간") + ) + + )); + } + + @DisplayName("이벤트 공지 사항 리스트를 조회할 수 있다.") + @Test + void getEventNoticeList() throws Exception { + + // given + EventNoticeListElementResponse eventNotice1 = new EventNoticeListElementResponse(1L, "event notice 1", 10, true, LocalDateTime.now(), LocalDateTime.now()); + EventNoticeListElementResponse eventNotice2 = new EventNoticeListElementResponse(2L, "event notice 2", 10, false, LocalDateTime.now(), LocalDateTime.now()); + Page page = new PageImpl<>(List.of(eventNotice1, eventNotice2), PageRequest.of(0, 10), 2); + + when(eventNoticeService.getEventNoticeList(any(String.class), any(String.class), any(Pageable.class))).thenReturn(page); + + // when + ResultActions result = mockMvc.perform( + get("/eventNotices") + .param("terms", "notice") + .param("scope", "title") + .param("page", "0") + .param("size", "10") + .contentType(APPLICATION_JSON) + ); + + // then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + queryParameters( + parameterWithName("terms").description("검색어 (optional)").optional(), + parameterWithName("scope").description("검색 범위 (title, content, both) [default: both, searchTerm=null 이면 null로 초기화]").optional(), + parameterWithName("page").description("페이지 번호 [default: 0]").optional(), + parameterWithName("size").description("페이지 크기 [default: 10]").optional() + ), + responseFields( + fieldWithPath("content[].id").type(JsonFieldType.NUMBER).description("이벤트 공지 사항 ID"), + fieldWithPath("content[].title").type(JsonFieldType.STRING).description("이벤트 공지 사항 제목"), + fieldWithPath("content[].hitCount").type(JsonFieldType.NUMBER).description("이벤트 공지 사항 조회수"), + fieldWithPath("content[].fixed").type(JsonFieldType.BOOLEAN).description("이벤트 공지 사항 고정 여부"), + fieldWithPath("content[].createdAt").type(JsonFieldType.STRING).description("이벤트 공지 사항 생성일"), + fieldWithPath("content[].updatedAt").type(JsonFieldType.STRING).description("이벤트 공지 사항 수정일"), + fieldWithPath("pageable").type(JsonFieldType.OBJECT).description("페이지 정보"), + fieldWithPath("pageable.pageNumber").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("pageable.pageSize").type(JsonFieldType.NUMBER).description("페이지 당 요소 수"), + fieldWithPath("pageable.sort.empty").type(JsonFieldType.BOOLEAN).description("정렬 정보가 비어있는지 여부"), + fieldWithPath("pageable.sort.sorted").type(JsonFieldType.BOOLEAN).description("정렬된 상태인지 여부"), + fieldWithPath("pageable.sort.unsorted").type(JsonFieldType.BOOLEAN).description("정렬되지 않은 상태인지 여부"), + fieldWithPath("pageable.offset").type(JsonFieldType.NUMBER).description("오프셋"), + fieldWithPath("pageable.paged").type(JsonFieldType.BOOLEAN).description("페이징된 여부"), + fieldWithPath("pageable.unpaged").type(JsonFieldType.BOOLEAN).description("페이징되지 않은 여부"), + fieldWithPath("totalElements").type(JsonFieldType.NUMBER).description("전체 요소 수"), + fieldWithPath("totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), + fieldWithPath("size").type(JsonFieldType.NUMBER).description("페이지 당 요소 수"), + fieldWithPath("number").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("numberOfElements").type(JsonFieldType.NUMBER).description("현재 페이지 요소 수"), + fieldWithPath("first").type(JsonFieldType.BOOLEAN).description("첫 페이지 여부"), + fieldWithPath("last").type(JsonFieldType.BOOLEAN).description("마지막 페이지 여부"), + fieldWithPath("sort.empty").type(JsonFieldType.BOOLEAN).description("정렬 정보가 비어있는지 여부"), + fieldWithPath("sort.unsorted").type(JsonFieldType.BOOLEAN).description("정렬되지 않은 상태인지 여부"), + fieldWithPath("sort.sorted").type(JsonFieldType.BOOLEAN).description("정렬된 상태인지 여부"), + fieldWithPath("empty").type(JsonFieldType.BOOLEAN).description("비어있는 페이지 여부") + ) + )); + } + + @DisplayName("이벤트 공지 사항을 조회할 수 있다.") + @Test + void getEventNotice() throws Exception { + + // given + List files = Arrays.asList( + new FileResponse(1L, "014eb8a0-d4a6-11ee-adac-117d766aca1d", "예시 첨부 파일 1.jpg", "image/jpeg", LocalDateTime.now(), LocalDateTime.now()), + new FileResponse(2L, "11a480c0-13fa-11ef-9047-570191b390ea", "예시 첨부 파일 2.jpg", "image/jpeg", LocalDateTime.now(), LocalDateTime.now()), + new FileResponse(3L, "1883fc70-cfb4-11ee-a387-e754bd392d45", "예시 첨부 파일 3.jpg", "image/jpeg", LocalDateTime.now(), LocalDateTime.now()) + ); + EventNoticeResponse response = new EventNoticeResponse(1L, "이벤트 공지 사항 제목", "content", 10, true, LocalDateTime.now(), LocalDateTime.now(), files); + + when(eventNoticeService.getEventNotice(any())).thenReturn(response); + + // when + ResultActions result = mockMvc.perform( + get("/eventNotices/{eventNoticeId}", 1L) + .contentType(APPLICATION_JSON) + ); + + // then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + pathParameters( + parameterWithName("eventNoticeId").description("조회할 이벤트 공지 사항 ID") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("이벤트 공지 사항 ID"), + fieldWithPath("title").type(JsonFieldType.STRING).description("이벤트 공지 사항 제목"), + fieldWithPath("content").type(JsonFieldType.STRING).description("이벤트 공지 사항 내용"), + fieldWithPath("hitCount").type(JsonFieldType.NUMBER).description("이벤트 공지 사항 조회수"), + fieldWithPath("fixed").type(JsonFieldType.BOOLEAN).description("이벤트 공지 사항 고정 여부"), + fieldWithPath("createdAt").type(JsonFieldType.STRING).description("이벤트 공지 사항 생성일"), + fieldWithPath("updatedAt").type(JsonFieldType.STRING).description("이벤트 공지 사항 수정일"), + fieldWithPath("files").type(JsonFieldType.ARRAY).description("첨부 파일 목록"), + fieldWithPath("files[].id").type(JsonFieldType.NUMBER).description("파일 ID"), + fieldWithPath("files[].uuid").type(JsonFieldType.STRING).description("파일 고유 식별자"), + fieldWithPath("files[].name").type(JsonFieldType.STRING).description("파일 이름"), + fieldWithPath("files[].mimeType").type(JsonFieldType.STRING).description("파일 MIME 타입"), + fieldWithPath("files[].createdAt").type(JsonFieldType.STRING).description("파일 생성 시간"), + fieldWithPath("files[].updatedAt").type(JsonFieldType.STRING).description("파일 수정 시간") + ) + + )); + } + + @DisplayName("이벤트 공지 사항을 수정할 수 있다.") + @Test + void updateEventNotice() throws Exception { + + // given + EventNoticeRequest request = new EventNoticeRequest("수정된 이벤트 공지 사항 제목", "수정된 이벤트 공지 사항 내용", false, List.of(1L, 2L, 3L)); + List files = Arrays.asList( + new FileResponse(1L, "014eb8a0-d4a6-11ee-adac-117d766aca1d", "예시 첨부 파일 1.jpg", "image/jpeg", LocalDateTime.of(2024, 1, 1, 12, 0), LocalDateTime.now()), + new FileResponse(2L, "11a480c0-13fa-11ef-9047-570191b390ea", "예시 첨부 파일 2.jpg", "image/jpeg", LocalDateTime.of(2024, 1, 1, 12, 0), LocalDateTime.now()), + new FileResponse(3L, "1883fc70-cfb4-11ee-a387-e754bd392d45", "예시 첨부 파일 3.jpg", "image/jpeg", LocalDateTime.of(2024, 1, 1, 12, 0), LocalDateTime.now()) + ); + EventNoticeResponse response = new EventNoticeResponse(1L, "수정된 이벤트 공지 사항 제목", "수정된 이벤트 공지 사항 내용", 10, false, LocalDateTime.of(2024, 1, 1, 12, 0), LocalDateTime.now(), files); + + when(eventNoticeService.updateEventNotice(anyLong(), any(EventNoticeRequest.class))).thenReturn(response); + + // when + ResultActions result = mockMvc.perform( + put("/eventNotices/{eventNoticeId}", 1L) + .contentType(APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + .content(objectMapper.writeValueAsString(request)) + ); + + // then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + pathParameters( + parameterWithName("eventNoticeId").description("수정할 이벤트 공지 사항 ID") + ), + requestFields( + fieldWithPath("title").type(JsonFieldType.STRING).description("이벤트 공지 사항 제목"), + fieldWithPath("content").type(JsonFieldType.STRING).description("이벤트 공지 사항 내용"), + fieldWithPath("fixed").type(JsonFieldType.BOOLEAN).description("이벤트 공지 사항 고정 여부"), + fieldWithPath("fileIds").type(JsonFieldType.ARRAY).description("첨부 파일 ID 목록").optional() + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("이벤트 공지 사항 ID"), + fieldWithPath("title").type(JsonFieldType.STRING).description("이벤트 공지 사항 제목"), + fieldWithPath("content").type(JsonFieldType.STRING).description("이벤트 공지 사항 내용"), + fieldWithPath("hitCount").type(JsonFieldType.NUMBER).description("이벤트 공지 사항 조회수"), + fieldWithPath("fixed").type(JsonFieldType.BOOLEAN).description("이벤트 공지 사항 고정 여부"), + fieldWithPath("createdAt").type(JsonFieldType.STRING).description("이벤트 공지 사항 생성일"), + fieldWithPath("updatedAt").type(JsonFieldType.STRING).description("이벤트 공지 사항 수정일"), + fieldWithPath("files").type(JsonFieldType.ARRAY).description("첨부 파일 목록"), + fieldWithPath("files[].id").type(JsonFieldType.NUMBER).description("파일 ID"), + fieldWithPath("files[].uuid").type(JsonFieldType.STRING).description("파일 고유 식별자"), + fieldWithPath("files[].name").type(JsonFieldType.STRING).description("파일 이름"), + fieldWithPath("files[].mimeType").type(JsonFieldType.STRING).description("파일 MIME 타입"), + fieldWithPath("files[].createdAt").type(JsonFieldType.STRING).description("파일 생성 시간"), + fieldWithPath("files[].updatedAt").type(JsonFieldType.STRING).description("파일 수정 시간") + ) + + )); + } + + + @DisplayName("이벤트 공지 사항을 삭제할 수 있다.") + @Test + void deleteEventNotice() throws Exception { + + // given + doNothing().when(eventNoticeService).deleteEventNotice(anyLong()); + + // when + ResultActions result = mockMvc.perform( + delete("/eventNotices/{eventNoticeId}", 1L) + .contentType(APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + // then + result.andExpect(status().isNoContent()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + pathParameters( + parameterWithName("eventNoticeId").description("삭제할 이벤트 공지 사항 ID") + ) + )); + } +} diff --git a/src/test/java/com/scg/stop/event/controller/EventPeriodControllerTest.java b/src/test/java/com/scg/stop/event/controller/EventPeriodControllerTest.java new file mode 100644 index 00000000..79512363 --- /dev/null +++ b/src/test/java/com/scg/stop/event/controller/EventPeriodControllerTest.java @@ -0,0 +1,190 @@ +package com.scg.stop.event.controller; + + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.put; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.scg.stop.configuration.AbstractControllerTest; +import com.scg.stop.event.controller.EventPeriodController; +import com.scg.stop.event.dto.request.EventPeriodRequest; +import com.scg.stop.event.dto.response.EventPeriodResponse; +import com.scg.stop.event.service.EventPeriodService; +import jakarta.servlet.http.Cookie; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.http.HttpHeaders; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.web.servlet.ResultActions; + +@WebMvcTest(EventPeriodController.class) +@MockBean(JpaMetamodelMappingContext.class) +@AutoConfigureRestDocs +class EventPeriodControllerTest extends AbstractControllerTest { + + private static final String ACCESS_TOKEN = "admin_access_token"; + private static final String REFRESH_TOKEN = "refresh_token"; + + @MockBean + private EventPeriodService eventPeriodService; + + @Autowired + private ObjectMapper objectMapper; + + @Test + @DisplayName("이벤트 기간을 생성할 수 있다.") + void createEventPeriod() throws Exception { + // given + EventPeriodRequest request = new EventPeriodRequest(LocalDateTime.now(), LocalDateTime.now().plusDays(10)); + EventPeriodResponse response = new EventPeriodResponse(1L, 2024, LocalDateTime.now(), + LocalDateTime.now().plusDays(10), LocalDateTime.now(), LocalDateTime.now()); + + when(eventPeriodService.createEventPeriod(any(EventPeriodRequest.class))).thenReturn(response); + + // when + ResultActions result = mockMvc.perform( + post("/eventPeriods") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + .header(HttpHeaders.AUTHORIZATION, ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + //then + result.andExpect(status().isCreated()) + .andDo(restDocs.document( + requestFields( + fieldWithPath("start").type(JsonFieldType.STRING).description("이벤트 시작 일시"), + fieldWithPath("end").type(JsonFieldType.STRING).description("이벤트 종료 일시") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("이벤트 기간 ID"), + fieldWithPath("year").type(JsonFieldType.NUMBER).description("이벤트 연도"), + fieldWithPath("start").type(JsonFieldType.STRING).description("이벤트 시작 일시"), + fieldWithPath("end").type(JsonFieldType.STRING).description("이벤트 종료 일시"), + fieldWithPath("createdAt").type(JsonFieldType.STRING).description("생성일"), + fieldWithPath("updatedAt").type(JsonFieldType.STRING).description("변경일") + ) + )); + } + + @Test + @DisplayName("올해의 이벤트 기간 설정을 조회할 수 있다.") + void getEventPeriod() throws Exception { + // given + EventPeriodResponse response = new EventPeriodResponse(1L, LocalDateTime.now().getYear(), + LocalDateTime.now(), LocalDateTime.now().plusDays(10), + LocalDateTime.now(), LocalDateTime.now()); + when(eventPeriodService.getEventPeriod()).thenReturn(response); + + // when + ResultActions result = mockMvc.perform(get("/eventPeriod") + .header(HttpHeaders.AUTHORIZATION, ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + // then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("이벤트 기간 ID"), + fieldWithPath("year").type(JsonFieldType.NUMBER).description("이벤트 연도"), + fieldWithPath("start").type(JsonFieldType.STRING).description("이벤트 시작 일시"), + fieldWithPath("end").type(JsonFieldType.STRING).description("이벤트 종료 일시"), + fieldWithPath("createdAt").type(JsonFieldType.STRING).description("생성일"), + fieldWithPath("updatedAt").type(JsonFieldType.STRING).description("변경일") + ) + )); + } + + @Test + @DisplayName("전체 이벤트 기간 설정을 조회할 수 있다.") + void getEventPeriods() throws Exception { + // given + List responses = Arrays.asList( + new EventPeriodResponse(1L, 2024, LocalDateTime.now(), LocalDateTime.now().plusDays(10), + LocalDateTime.now(), LocalDateTime.now()), + new EventPeriodResponse(2L, 2025, LocalDateTime.now(), LocalDateTime.now().plusDays(10), + LocalDateTime.now(), LocalDateTime.now()) + ); + when(eventPeriodService.getEventPeriods()).thenReturn(responses); + + // when + ResultActions result = mockMvc.perform(get("/eventPeriods") + .header(HttpHeaders.AUTHORIZATION, ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + // then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + responseFields( + fieldWithPath("[].id").type(JsonFieldType.NUMBER).description("이벤트 기간 ID"), + fieldWithPath("[].year").type(JsonFieldType.NUMBER).description("이벤트 연도"), + fieldWithPath("[].start").type(JsonFieldType.STRING).description("이벤트 시작 일시"), + fieldWithPath("[].end").type(JsonFieldType.STRING).description("이벤트 종료 일시"), + fieldWithPath("[].createdAt").type(JsonFieldType.STRING).description("생성일"), + fieldWithPath("[].updatedAt").type(JsonFieldType.STRING).description("변경일") + ) + )); + } + + @Test + @DisplayName("이벤트 기간을 수정할 수 있다.") + void updateEventPeriod() throws Exception { + // given + EventPeriodRequest request = new EventPeriodRequest(LocalDateTime.now(), LocalDateTime.now().plusDays(10)); + EventPeriodResponse response = new EventPeriodResponse(1L, LocalDateTime.now().getYear(), + LocalDateTime.now(), LocalDateTime.now().plusDays(10), + LocalDateTime.now(), LocalDateTime.now()); + + when(eventPeriodService.updateEventPeriod(any(EventPeriodRequest.class))).thenReturn(response); + + // when + ResultActions result = mockMvc.perform( + put("/eventPeriod") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + .header(HttpHeaders.AUTHORIZATION, ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + // then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + requestFields( + fieldWithPath("start").type(JsonFieldType.STRING).description("이벤트 시작 일시"), + fieldWithPath("end").type(JsonFieldType.STRING).description("이벤트 종료 일시") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("이벤트 기간 ID"), + fieldWithPath("year").type(JsonFieldType.NUMBER).description("이벤트 연도"), + fieldWithPath("start").type(JsonFieldType.STRING).description("이벤트 시작 일시"), + fieldWithPath("end").type(JsonFieldType.STRING).description("이벤트 종료 일시"), + fieldWithPath("createdAt").type(JsonFieldType.STRING).description("생성일"), + fieldWithPath("updatedAt").type(JsonFieldType.STRING).description("변경일") + ) + )); + } +} \ No newline at end of file diff --git a/src/test/java/com/scg/stop/exception/ExceptionCodeController.java b/src/test/java/com/scg/stop/exception/ExceptionCodeController.java new file mode 100644 index 00000000..29133672 --- /dev/null +++ b/src/test/java/com/scg/stop/exception/ExceptionCodeController.java @@ -0,0 +1,26 @@ +package com.scg.stop.exception; + +import com.scg.stop.global.exception.ExceptionCode; +import com.scg.stop.global.exception.ExceptionResponse; +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; +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("/exceptionCodes") +public class ExceptionCodeController { + + @GetMapping + public ResponseEntity> getExceptionCodes() { + final Map exceptionResponses = Arrays.stream(ExceptionCode.values()) + .collect(Collectors.toMap( + Enum::name, + code -> new ExceptionResponse(code.getCode(), code.getMessage()) + )); + return ResponseEntity.ok().body(exceptionResponses); + } +} \ No newline at end of file diff --git a/src/test/java/com/scg/stop/exception/ExceptionCodeControllerTest.java b/src/test/java/com/scg/stop/exception/ExceptionCodeControllerTest.java new file mode 100644 index 00000000..2f73e2cf --- /dev/null +++ b/src/test/java/com/scg/stop/exception/ExceptionCodeControllerTest.java @@ -0,0 +1,69 @@ +package com.scg.stop.exception; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.snippet.Attributes.key; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.scg.stop.auth.config.AuthUserArgumentResolver; +import com.scg.stop.configuration.AbstractControllerTest; +import com.scg.stop.global.exception.ExceptionCode; +import com.scg.stop.global.exception.ExceptionResponse; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.restdocs.payload.FieldDescriptor; +import org.springframework.test.web.servlet.MvcResult; + +@WebMvcTest(ExceptionCodeController.class) +@MockBean(JpaMetamodelMappingContext.class) +@AutoConfigureRestDocs +public class ExceptionCodeControllerTest extends AbstractControllerTest { + + @Autowired + private ObjectMapper objectMapper; + + @DisplayName("작성된 커스텀 예외 코드를 반환한다.") + @Test + void getExceptionCodes() throws Exception { + // when & then + final MvcResult mvcResult = mockMvc.perform(RestDocumentationRequestBuilders.get("/exceptionCodes")) + .andExpect(status().isOk()) + .andDo(restDocs.document( + new ExceptionResponseFieldsSnippet( + "exception-response", + convertExceptionCodesToFieldDescriptors(ExceptionCode.values()), + true + ) + )).andReturn(); + + final Map exceptionResponses = objectMapper.readValue( + mvcResult.getResponse().getContentAsString(), + new TypeReference<>() { + } + ); + + assertThat(exceptionResponses.size()).isEqualTo(ExceptionCode.values().length); + } + + private List convertExceptionCodesToFieldDescriptors(final ExceptionCode[] exceptionCodes) { + return Arrays.stream(Arrays.stream(exceptionCodes) + .map(code -> fieldWithPath(code.name()).description(code.getMessage()) + .attributes( + key("code").value(code.getCode()), + key("message").value(code.getMessage())) + ).toArray(FieldDescriptor[]::new)).toList(); + } +} diff --git a/src/test/java/com/scg/stop/exception/ExceptionResponseFieldsSnippet.java b/src/test/java/com/scg/stop/exception/ExceptionResponseFieldsSnippet.java new file mode 100644 index 00000000..d2d8abc2 --- /dev/null +++ b/src/test/java/com/scg/stop/exception/ExceptionResponseFieldsSnippet.java @@ -0,0 +1,28 @@ +package com.scg.stop.exception; + +import java.util.List; +import org.springframework.http.MediaType; +import org.springframework.restdocs.operation.Operation; +import org.springframework.restdocs.payload.AbstractFieldsSnippet; +import org.springframework.restdocs.payload.FieldDescriptor; + +public class ExceptionResponseFieldsSnippet extends AbstractFieldsSnippet { + + public ExceptionResponseFieldsSnippet( + final String type, + final List descriptors, + final boolean ignoreUndocumentedFields + ) { + super(type, descriptors, null, ignoreUndocumentedFields); + } + + @Override + protected MediaType getContentType(final Operation operation) { + return operation.getResponse().getHeaders().getContentType(); + } + + @Override + protected byte[] getContent(final Operation operation) { + return operation.getResponse().getContent(); + } +} \ No newline at end of file diff --git a/src/test/java/com/scg/stop/file/controller/FileControllerTest.java b/src/test/java/com/scg/stop/file/controller/FileControllerTest.java new file mode 100644 index 00000000..4ddabe2b --- /dev/null +++ b/src/test/java/com/scg/stop/file/controller/FileControllerTest.java @@ -0,0 +1,183 @@ +package com.scg.stop.file.controller; + +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; +import static org.springframework.restdocs.cookies.CookieDocumentation.requestCookies; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.headers.HeaderDocumentation.responseHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.multipart; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.partWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.request.RequestDocumentation.requestParts; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.scg.stop.configuration.AbstractControllerTest; +import com.scg.stop.file.domain.File; +import com.scg.stop.file.dto.response.FileResponse; +import com.scg.stop.file.service.FileService; +import jakarta.servlet.http.Cookie; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.web.servlet.ResultActions; + +@WebMvcTest(FileController.class) +@MockBean(JpaMetamodelMappingContext.class) +@AutoConfigureRestDocs +class FileControllerTest extends AbstractControllerTest { + + @MockBean + private FileService fileService; + + private static final String ACCESS_TOKEN = "admin_access_token"; + private static final String REFRESH_TOKEN = "refresh_token"; + + @Test + @DisplayName("여러개의 파일을 업로드할 수 있다.") + void uploadFiles() throws Exception { + // given + MockMultipartFile mockFile1 = new MockMultipartFile( + "files", + "첨부파일1.png", + "image/png", + "[BINARY DATA - PNG IMAGE CONTENT]".getBytes(StandardCharsets.UTF_8) + ); + MockMultipartFile mockFile2 = new MockMultipartFile( + "files", + "첨부파일2.pdf", + "application/pdf", + "[BINARY DATA - PDF CONTENT]".getBytes(StandardCharsets.UTF_8) + ); + FileResponse response1 = new FileResponse( + 1L, + UUID.randomUUID().toString(), + "첨부파일1.png", + "image/png", + LocalDateTime.now(), + LocalDateTime.now() + ); + FileResponse response2 = new FileResponse( + 2L, + UUID.randomUUID().toString(), + "첨부파일2.pdf", + "application/pdf", + LocalDateTime.now(), + LocalDateTime.now() + ); + List responses = List.of(response1, response2); + + when(fileService.uploadFiles(anyList())).thenReturn(responses); + + // when + ResultActions result = mockMvc.perform( + multipart("/files") + .file(mockFile1) + .file(mockFile2) + .contentType(MediaType.MULTIPART_FORM_DATA) + ); + + // then + result.andExpect(status().isCreated()) + .andDo(restDocs.document( + requestParts( + partWithName("files").description("업로드할 파일 리스트") + ), + responseFields( + fieldWithPath("[].id").type(JsonFieldType.NUMBER).description("파일 ID"), + fieldWithPath("[].uuid").type(JsonFieldType.STRING).description("파일 UUID"), + fieldWithPath("[].name").type(JsonFieldType.STRING).description("파일 이름"), + fieldWithPath("[].mimeType").type(JsonFieldType.STRING).description("파일의 MIME 타입"), + fieldWithPath("[].createdAt").type(JsonFieldType.STRING).description("파일 생성일"), + fieldWithPath("[].updatedAt").type(JsonFieldType.STRING).description("파일 수정일") + ) + )); + } + + @Test + @DisplayName("파일을 가져올 수 있다.") + void getFile() throws Exception { + // given + Long fileId = 1L; + String fileName = "s-top 로고.png"; + String mimeType = "image/png"; + byte[] fileContent = "[BINARY DATA - PNG IMAGE CONTENT]".getBytes(StandardCharsets.UTF_8); + + InputStream stream = new ByteArrayInputStream(fileContent); + File file = File.of(UUID.randomUUID().toString(), fileName, mimeType); + + when(fileService.getFile(fileId)).thenReturn(stream); + when(fileService.getFileMetadata(fileId)).thenReturn(file); + + // when + ResultActions result = mockMvc.perform( + get("/files/{fileId}", fileId) + ); + + // then + result.andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.parseMediaType(file.getMimeType()))) + .andDo(restDocs.document( + pathParameters( + parameterWithName("fileId").description("파일 ID") + ), + responseHeaders( + headerWithName(HttpHeaders.CONTENT_TYPE).description("파일의 MIME 타입") + ) + )); + } + + @Test + @DisplayName("프로젝트 일괄등록 양식을 가져올 수 있다.") + void getProjectExcelForm() throws Exception { + // given + String directoryPath = "form/"; + String fileName = "project_upload_form.xlsx"; + byte[] content = "project_upload_form".getBytes(StandardCharsets.UTF_8); + ByteArrayInputStream inputStream = new ByteArrayInputStream(content); + Resource mockResource = new InputStreamResource(inputStream); + when(fileService.getLocalFile(directoryPath, fileName)).thenReturn(mockResource); + + // when + ResultActions result = mockMvc.perform( + get("/files/form/projects") + .header(HttpHeaders.AUTHORIZATION, ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + // then + result.andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_OCTET_STREAM)) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ) + )); + } +} diff --git a/src/test/java/com/scg/stop/gallery/controller/GalleryControllerTest.java b/src/test/java/com/scg/stop/gallery/controller/GalleryControllerTest.java new file mode 100644 index 00000000..80dd4d21 --- /dev/null +++ b/src/test/java/com/scg/stop/gallery/controller/GalleryControllerTest.java @@ -0,0 +1,348 @@ +package com.scg.stop.gallery.controller; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; +import static org.springframework.restdocs.cookies.CookieDocumentation.requestCookies; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; +import static org.springframework.restdocs.request.RequestDocumentation.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.scg.stop.configuration.AbstractControllerTest; +import com.scg.stop.file.dto.response.FileResponse; +import com.scg.stop.gallery.dto.request.GalleryRequest; +import com.scg.stop.gallery.dto.response.GalleryResponse; +import com.scg.stop.gallery.service.GalleryService; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.http.HttpHeaders; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.web.servlet.ResultActions; + +@WebMvcTest(controllers = GalleryController.class) +@MockBean(JpaMetamodelMappingContext.class) +@AutoConfigureRestDocs +class GalleryControllerTest extends AbstractControllerTest { + + private static final String ACCESS_TOKEN = "admin_access_token"; + private static final String REFRESH_TOKEN = "refresh_token"; + + @MockBean + private GalleryService galleryService; + + @Autowired + private ObjectMapper objectMapper; + + @Test + @DisplayName("갤러리 게시글을 생성할 수 있다.") + void createGallery() throws Exception { + + // given + List fileIds = Arrays.asList(1L, 2L, 3L); + List fileResponses = Arrays.asList( + new FileResponse(1L, "014eb8a0-d4a6-11ee-adac-117d766aca1d", "사진1.jpg", "image/jpeg", LocalDateTime.now(), LocalDateTime.now()), + new FileResponse(2L, "11a480c0-13fa-11ef-9047-570191b390ea", "사진2.jpg", "image/jpeg", LocalDateTime.now(), LocalDateTime.now()), + new FileResponse(3L, "1883fc70-cfb4-11ee-a387-e754bd392d45", "사진3.jpg", "image/jpeg", LocalDateTime.now(), LocalDateTime.now()) + ); + GalleryRequest request = new GalleryRequest("새내기 배움터", 2024, 4, fileIds); + GalleryResponse response = new GalleryResponse( + 1L, + "새내기 배움터", + 2024, + 4, + 1, + LocalDateTime.now(), + LocalDateTime.now(), + fileResponses + ); + when(galleryService.createGallery(any(GalleryRequest.class))).thenReturn(response); + + // when + ResultActions result = mockMvc.perform( + post("/galleries") + .header(HttpHeaders.AUTHORIZATION, ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + + // then + result.andExpect(status().isCreated()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token").description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization").description("access token") + ), + requestFields( + fieldWithPath("title").type(JsonFieldType.STRING).description("제목"), + fieldWithPath("year").type(JsonFieldType.NUMBER).description("연도"), + fieldWithPath("month").type(JsonFieldType.NUMBER).description("월"), + fieldWithPath("fileIds").type(JsonFieldType.ARRAY).description("파일 ID 리스트") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("갤러리 ID"), + fieldWithPath("title").type(JsonFieldType.STRING).description("제목"), + fieldWithPath("year").type(JsonFieldType.NUMBER).description("연도"), + fieldWithPath("month").type(JsonFieldType.NUMBER).description("월"), + fieldWithPath("hitCount").type(JsonFieldType.NUMBER).description("조회수"), + fieldWithPath("createdAt").type(JsonFieldType.STRING).description("생성일"), + fieldWithPath("updatedAt").type(JsonFieldType.STRING).description("수정일"), + fieldWithPath("files").type(JsonFieldType.ARRAY).description("파일 목록"), + fieldWithPath("files[].id").type(JsonFieldType.NUMBER).description("파일 ID"), + fieldWithPath("files[].uuid").type(JsonFieldType.STRING).description("파일 UUID"), + fieldWithPath("files[].name").type(JsonFieldType.STRING).description("파일 이름"), + fieldWithPath("files[].mimeType").type(JsonFieldType.STRING).description("파일 MIME 타입"), + fieldWithPath("files[].createdAt").type(JsonFieldType.STRING).description("파일 생성일"), + fieldWithPath("files[].updatedAt").type(JsonFieldType.STRING).description("파일 수정일") + ) + )); + } + + @Test + @DisplayName("갤러리 게시글 목록을 조회할 수 있다.") + void getGalleries() throws Exception { + + // given + List fileResponses = Arrays.asList( + new FileResponse(1L, "014eb8a0-d4a6-11ee-adac-117d766aca1d", "사진1.jpg", "image/jpeg", LocalDateTime.now(), LocalDateTime.now()), + new FileResponse(2L, "11a480c0-13fa-11ef-9047-570191b390ea", "사진2.jpg", "image/jpeg", LocalDateTime.now(), LocalDateTime.now()), + new FileResponse(3L, "1883fc70-cfb4-11ee-a387-e754bd392d45", "사진3.jpg", "image/jpeg", LocalDateTime.now(), LocalDateTime.now()) + ); + GalleryResponse galleryResponse = new GalleryResponse( + 1L, + "새내기 배움터", + 2024, + 4, + 0, // getGalleries 에선 조회수를 증가시키지 않음 + LocalDateTime.now(), + LocalDateTime.now(), + fileResponses + ); + PageImpl galleryResponses = new PageImpl<>(Collections.singletonList(galleryResponse)); + when(galleryService.getGalleries(anyInt(), anyInt(), any(Pageable.class))).thenReturn(galleryResponses); + + // when + ResultActions result = mockMvc.perform( + get("/galleries") + .param("year", "2024") + .param("month", "4") + .contentType(APPLICATION_JSON) + ); + + // then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + queryParameters( + parameterWithName("year").optional().description("연도"), + parameterWithName("month").optional().description("월"), + parameterWithName("page").optional().description("페이지 번호 [default: 0]"), + parameterWithName("size").optional().description("페이지 크기 [default: 10]") + ), + responseFields( + fieldWithPath("totalElements").type(JsonFieldType.NUMBER).description("전체 데이터 수"), + fieldWithPath("totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), + fieldWithPath("size").type(JsonFieldType.NUMBER).description("페이지당 데이터 수"), + fieldWithPath("content").type(JsonFieldType.ARRAY).description("갤러리 목록"), + fieldWithPath("content[].id").type(JsonFieldType.NUMBER).description("갤러리 ID"), + fieldWithPath("content[].title").type(JsonFieldType.STRING).description("갤러리 제목"), + fieldWithPath("content[].year").type(JsonFieldType.NUMBER).description("갤러리 연도"), + fieldWithPath("content[].month").type(JsonFieldType.NUMBER).description("갤러리 월"), + fieldWithPath("content[].hitCount").type(JsonFieldType.NUMBER).description("갤러리 조회수"), + fieldWithPath("content[].createdAt").type(JsonFieldType.STRING).description("갤러리 생성일"), + fieldWithPath("content[].updatedAt").type(JsonFieldType.STRING).description("갤러리 수정일"), + fieldWithPath("content[].files").type(JsonFieldType.ARRAY).description("파일 목록"), + fieldWithPath("content[].files[].id").type(JsonFieldType.NUMBER).description("파일 ID"), + fieldWithPath("content[].files[].uuid").type(JsonFieldType.STRING).description("파일 UUID"), + fieldWithPath("content[].files[].name").type(JsonFieldType.STRING).description("파일 이름"), + fieldWithPath("content[].files[].mimeType").type(JsonFieldType.STRING).description("파일 MIME 타입"), + fieldWithPath("content[].files[].createdAt").type(JsonFieldType.STRING).description("파일 생성일"), + fieldWithPath("content[].files[].updatedAt").type(JsonFieldType.STRING).description("파일 수정일"), + fieldWithPath("number").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("sort.empty").type(JsonFieldType.BOOLEAN).description("정렬 정보"), + fieldWithPath("sort.sorted").type(JsonFieldType.BOOLEAN).description("정렬 정보"), + fieldWithPath("sort.unsorted").type(JsonFieldType.BOOLEAN).description("정렬 정보"), + fieldWithPath("first").type(JsonFieldType.BOOLEAN).description("첫 페이지 여부"), + fieldWithPath("last").type(JsonFieldType.BOOLEAN).description("마지막 페이지 여부"), + fieldWithPath("numberOfElements").type(JsonFieldType.NUMBER).description("현재 페이지의 데이터 수"), + fieldWithPath("pageable").ignored(), + fieldWithPath("empty").type(JsonFieldType.BOOLEAN).description("빈 페이지 여부") + ) + )); + } + + @Test + @DisplayName("id로 갤러리 게시글을 조회할 수 있다.") + void getGallery() throws Exception { + + // given + List fileResponses = Arrays.asList( + new FileResponse(1L, "014eb8a0-d4a6-11ee-adac-117d766aca1d", "사진1.jpg", "image/jpeg", LocalDateTime.now(), LocalDateTime.now()), + new FileResponse(2L, "11a480c0-13fa-11ef-9047-570191b390ea", "사진2.jpg", "image/jpeg", LocalDateTime.now(), LocalDateTime.now()), + new FileResponse(3L, "1883fc70-cfb4-11ee-a387-e754bd392d45", "사진3.jpg", "image/jpeg", LocalDateTime.now(), LocalDateTime.now()) + ); + GalleryResponse galleryResponse = new GalleryResponse( + 1L, + "새내기 배움터", + 2024, + 4, + 1, + LocalDateTime.now(), + LocalDateTime.now(), + fileResponses + ); + when(galleryService.getGallery(1L)).thenReturn(galleryResponse); + + // when + ResultActions result = mockMvc.perform( + get("/galleries/{galleryId}", 1L) + .contentType(APPLICATION_JSON) + ); + + // then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + pathParameters( + parameterWithName("galleryId").description("조회할 갤러리 ID") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("갤러리 ID"), + fieldWithPath("title").type(JsonFieldType.STRING).description("제목"), + fieldWithPath("year").type(JsonFieldType.NUMBER).description("연도"), + fieldWithPath("month").type(JsonFieldType.NUMBER).description("월"), + fieldWithPath("hitCount").type(JsonFieldType.NUMBER).description("조회수"), + fieldWithPath("createdAt").type(JsonFieldType.STRING).description("생성일"), + fieldWithPath("updatedAt").type(JsonFieldType.STRING).description("수정일"), + fieldWithPath("files").type(JsonFieldType.ARRAY).description("파일 목록"), + fieldWithPath("files[].id").type(JsonFieldType.NUMBER).description("파일 ID"), + fieldWithPath("files[].uuid").type(JsonFieldType.STRING).description("파일 UUID"), + fieldWithPath("files[].name").type(JsonFieldType.STRING).description("파일 이름"), + fieldWithPath("files[].mimeType").type(JsonFieldType.STRING).description("파일 MIME 타입"), + fieldWithPath("files[].createdAt").type(JsonFieldType.STRING).description("파일 생성일"), + fieldWithPath("files[].updatedAt").type(JsonFieldType.STRING).description("파일 수정일") + ) + )); + } + + @Test + @DisplayName("갤러리 게시글을 수정할 수 있다.") + void updateGallery() throws Exception { + + // given + List fileIds = Arrays.asList(1L, 2L, 3L); + List fileResponses = Arrays.asList( + new FileResponse(1L, "014eb8a0-d4a6-11ee-adac-117d766aca1d", "사진1.jpg", "image/jpeg", LocalDateTime.now(), LocalDateTime.now()), + new FileResponse(2L, "11a480c0-13fa-11ef-9047-570191b390ea", "사진2.jpg", "image/jpeg", LocalDateTime.now(), LocalDateTime.now()), + new FileResponse(3L, "1883fc70-cfb4-11ee-a387-e754bd392d45", "사진3.jpg", "image/jpeg", LocalDateTime.now(), LocalDateTime.now()) + ); + GalleryRequest request = new GalleryRequest("수정된 제목", 2024, 5, fileIds); + GalleryResponse response = new GalleryResponse( + 1L, + "수정된 제목", + 2024, + 5, + 1, + LocalDateTime.now(), + LocalDateTime.now(), + fileResponses + ); + when(galleryService.updateGallery(anyLong(), any(GalleryRequest.class))).thenReturn(response); + + // when + ResultActions result = mockMvc.perform( + put("/galleries/{galleryId}", 1L) + .header(HttpHeaders.AUTHORIZATION, ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + + // then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token").description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization").description("access token") + ), + pathParameters( + parameterWithName("galleryId").description("수정할 갤러리 ID") + ), + requestFields( + fieldWithPath("title").type(JsonFieldType.STRING).description("제목"), + fieldWithPath("year").type(JsonFieldType.NUMBER).description("연도"), + fieldWithPath("month").type(JsonFieldType.NUMBER).description("월"), + fieldWithPath("fileIds").type(JsonFieldType.ARRAY).description("파일 ID 리스트") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("갤러리 ID"), + fieldWithPath("title").type(JsonFieldType.STRING).description("제목"), + fieldWithPath("year").type(JsonFieldType.NUMBER).description("연도"), + fieldWithPath("month").type(JsonFieldType.NUMBER).description("월"), + fieldWithPath("hitCount").type(JsonFieldType.NUMBER).description("조회수"), + fieldWithPath("createdAt").type(JsonFieldType.STRING).description("생성일"), + fieldWithPath("updatedAt").type(JsonFieldType.STRING).description("수정일"), + fieldWithPath("files").type(JsonFieldType.ARRAY).description("파일 목록"), + fieldWithPath("files[].id").type(JsonFieldType.NUMBER).description("파일 ID"), + fieldWithPath("files[].uuid").type(JsonFieldType.STRING).description("파일 UUID"), + fieldWithPath("files[].name").type(JsonFieldType.STRING).description("파일 이름"), + fieldWithPath("files[].mimeType").type(JsonFieldType.STRING).description("파일 MIME 타입"), + fieldWithPath("files[].createdAt").type(JsonFieldType.STRING).description("파일 생성일"), + fieldWithPath("files[].updatedAt").type(JsonFieldType.STRING).description("파일 수정일") + ) + )); + } + + @Test + @DisplayName("갤러리 게시글을 삭제할 수 있다.") + void deleteGallery() throws Exception { + + // given + Long galleryId = 1L; + doNothing().when(galleryService).deleteGallery(galleryId); + + // when + ResultActions result = mockMvc.perform( + delete("/galleries/{galleryId}", galleryId) + .header(HttpHeaders.AUTHORIZATION, ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + // then + result.andExpect(status().isNoContent()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token").description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization").description("access token") + ), + pathParameters( + parameterWithName("galleryId").description("삭제할 갤러리 ID") + ) + )); + } +} \ No newline at end of file diff --git a/src/test/java/com/scg/stop/inquiry/controller/InquiryControllerTest.java b/src/test/java/com/scg/stop/inquiry/controller/InquiryControllerTest.java new file mode 100644 index 00000000..4bb81d54 --- /dev/null +++ b/src/test/java/com/scg/stop/inquiry/controller/InquiryControllerTest.java @@ -0,0 +1,482 @@ +package com.scg.stop.inquiry.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.scg.stop.configuration.AbstractControllerTest; +import com.scg.stop.project.controller.InquiryController; +import com.scg.stop.project.controller.ProjectController; +import com.scg.stop.project.dto.request.InquiryReplyRequest; +import com.scg.stop.project.dto.request.InquiryRequest; +import com.scg.stop.project.dto.response.InquiryDetailResponse; +import com.scg.stop.project.dto.response.InquiryReplyResponse; +import com.scg.stop.project.dto.response.InquiryResponse; +import com.scg.stop.project.service.InquiryService; +import com.scg.stop.project.service.ProjectService; +import com.scg.stop.user.domain.User; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.http.HttpHeaders; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.web.servlet.ResultActions; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; +import static org.springframework.restdocs.cookies.CookieDocumentation.requestCookies; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest({ + InquiryController.class, + ProjectController.class +}) +@MockBean(JpaMetamodelMappingContext.class) +@AutoConfigureRestDocs +public class InquiryControllerTest extends AbstractControllerTest { + + private static final String ADMIN_ACCESS_TOKEN = "admin_access_token"; + private static final String COMPANY_ACCESS_TOKEN = "company_access_token"; + private static final String REFRESH_TOKEN = "refresh_token"; + + + @MockBean + private InquiryService inquiryService; + + @MockBean + private ProjectService projectService; + + @Autowired + private ObjectMapper objectMapper; + + @Test + @DisplayName("프로젝트 문의를 생성할 수 있다.") + void createInquiry() throws Exception { + // given + InquiryRequest request = new InquiryRequest("프로젝트 문의 사항 제목", "프로젝트 문의 사항 내용"); + InquiryDetailResponse response = InquiryDetailResponse.of(1L, "문의 작성자 이름", 1L, "프로젝트 이름", "문의 사항 제목", "문의 사항 내용", false, LocalDateTime.now(), LocalDateTime.now()); + + when(projectService.createProjectInquiry(anyLong(), any(User.class), any(InquiryRequest.class))).thenReturn(response); + + // when + ResultActions result = mockMvc.perform( + post("/projects/{projectId}/inquiry", 1L) + .contentType(APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, COMPANY_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + .content(objectMapper.writeValueAsString(request)) + + ); + + // then + result.andExpect(status().isCreated()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + pathParameters( + parameterWithName("projectId").description("문의할 프로젝트 ID") + ), + requestFields( + fieldWithPath("title").type(JsonFieldType.STRING).description("문의 사항 제목"), + fieldWithPath("content").type(JsonFieldType.STRING).description("문의 사항 내용") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("문의 사항 ID"), + fieldWithPath("authorName").type(JsonFieldType.STRING).description("문의 작성자 이름"), + fieldWithPath("projectId").type(JsonFieldType.NUMBER).description("문의 대상 프로젝트 ID"), + fieldWithPath("projectName").type(JsonFieldType.STRING).description("문의 대상 프로젝트 이름"), + fieldWithPath("title").type(JsonFieldType.STRING).description("문의 사항 제목"), + fieldWithPath("content").type(JsonFieldType.STRING).description("문의 사항 내용"), + fieldWithPath("replied").type(JsonFieldType.BOOLEAN).description("답변 등록 여부"), + fieldWithPath("createdAt").type(JsonFieldType.STRING).description("문의 사항 생성 시간"), + fieldWithPath("updatedAt").type(JsonFieldType.STRING).description("문의 사항 수정 시간") + ) + )); + } + + @Test + @DisplayName("프로젝트 문의 리스트를 조회할 수 있다.") + void getInquiries() throws Exception { + + // given + InquiryResponse response1 = InquiryResponse.of(1L, "프로젝트 inquiry (문의) 제목 1", "프로젝트 문의 사항 내용", LocalDateTime.now()); + InquiryResponse response2 = InquiryResponse.of(2L, "프로젝트 inquiry (문의) 제목 2", "프로젝트 문의 사항 내용", LocalDateTime.now()); + Page page = new PageImpl<>(List.of(response1, response2), PageRequest.of(0, 10), 2); + + when(inquiryService.getInquiryList(any(String.class),any(String.class), any(Pageable.class))).thenReturn(page); + // when + ResultActions result = mockMvc.perform( + get("/inquiries") + .param("terms", "inquiry") + .param("scope", "title") + .param("page", "0") + .param("size", "10") + .contentType(APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, COMPANY_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + // then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + queryParameters( + parameterWithName("terms").description("검색어").optional(), + parameterWithName("scope").description("검색 범위 (title, content, both, author) [default: both, searchTerm=null 이면 null로 초기화]").optional(), + parameterWithName("page").description("페이지 번호 [default: 0]").optional(), + parameterWithName("size").description("페이지 크기 [default: 10]").optional() + ), + responseFields( + fieldWithPath("content[].id").type(JsonFieldType.NUMBER).description("문의 사항 ID"), + fieldWithPath("content[].authorName").type(JsonFieldType.STRING).description("문의 작성자 이름"), + fieldWithPath("content[].title").type(JsonFieldType.STRING).description("문의 사항 제목"), + fieldWithPath("content[].createdAt").type(JsonFieldType.STRING).description("문의 사항 생성 시간"), + fieldWithPath("pageable.pageNumber").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("pageable.pageSize").type(JsonFieldType.NUMBER).description("페이지 당 요소 수"), + fieldWithPath("pageable.sort.empty").type(JsonFieldType.BOOLEAN).description("정렬 정보가 비어있는지 여부"), + fieldWithPath("pageable.sort.sorted").type(JsonFieldType.BOOLEAN).description("정렬된 상태인지 여부"), + fieldWithPath("pageable.sort.unsorted").type(JsonFieldType.BOOLEAN).description("정렬되지 않은 상태인지 여부"), + fieldWithPath("pageable.offset").type(JsonFieldType.NUMBER).description("오프셋"), + fieldWithPath("pageable.paged").type(JsonFieldType.BOOLEAN).description("페이징된 여부"), + fieldWithPath("pageable.unpaged").type(JsonFieldType.BOOLEAN).description("페이징되지 않은 여부"), + fieldWithPath("totalElements").type(JsonFieldType.NUMBER).description("전체 요소 수"), + fieldWithPath("totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), + fieldWithPath("size").type(JsonFieldType.NUMBER).description("페이지 당 요소 수"), + fieldWithPath("number").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("numberOfElements").type(JsonFieldType.NUMBER).description("현재 페이지 요소 수"), + fieldWithPath("first").type(JsonFieldType.BOOLEAN).description("첫 페이지 여부"), + fieldWithPath("last").type(JsonFieldType.BOOLEAN).description("마지막 페이지 여부"), + fieldWithPath("sort.empty").type(JsonFieldType.BOOLEAN).description("정렬 정보가 비어있는지 여부"), + fieldWithPath("sort.unsorted").type(JsonFieldType.BOOLEAN).description("정렬되지 않은 상태인지 여부"), + fieldWithPath("sort.sorted").type(JsonFieldType.BOOLEAN).description("정렬된 상태인지 여부"), + fieldWithPath("empty").type(JsonFieldType.BOOLEAN).description("비어있는 페이지 여부") + ) + )); + } + + @Test + @DisplayName("프로젝트 문의를 단건 조회 할 수 있다.") + void getInquiry() throws Exception { + + // given + InquiryDetailResponse response = InquiryDetailResponse.of(1L, "문의 작성자 이름", 1L, "프로젝트 이름", "문의 사항 제목", "문의 사항 내용", false, LocalDateTime.now(), LocalDateTime.now()); + + when(inquiryService.getInquiry(anyLong(), any(User.class))).thenReturn(response); + + // when + ResultActions result = mockMvc.perform( + get("/inquiries/{inquiryId}", 1L) + .contentType(APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, COMPANY_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + // then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + pathParameters( + parameterWithName("inquiryId").description("조회할 문의 사항 ID") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("문의 사항 ID"), + fieldWithPath("authorName").type(JsonFieldType.STRING).description("문의 작성자 이름"), + fieldWithPath("projectId").type(JsonFieldType.NUMBER).description("문의 대상 프로젝트 ID"), + fieldWithPath("projectName").type(JsonFieldType.STRING).description("문의 대상 프로젝트 이름"), + fieldWithPath("title").type(JsonFieldType.STRING).description("문의 사항 제목"), + fieldWithPath("content").type(JsonFieldType.STRING).description("문의 사항 내용"), + fieldWithPath("replied").type(JsonFieldType.BOOLEAN).description("답변 등록 여부"), + fieldWithPath("createdAt").type(JsonFieldType.STRING).description("문의 사항 생성 시간"), + fieldWithPath("updatedAt").type(JsonFieldType.STRING).description("문의 사항 수정 시간") + ) + )); + + } + + @Test + @DisplayName("프로젝트 문의를 수정 할 수 있다.") + void updateInquiry() throws Exception { + + // given + InquiryRequest request = new InquiryRequest("수정된 프로젝트 문의 사항 제목", "수정된 프로젝트 문의 사항 내용"); + InquiryDetailResponse response = InquiryDetailResponse.of(1L, "문의 작성자 이름", 1L, "프로젝트 이름", "수정된 문의 사항 제목", "수정된 문의 사항 내용", false ,LocalDateTime.now(), LocalDateTime.now()); + + when(inquiryService.updateInquiry(anyLong(), any(User.class), any(InquiryRequest.class))).thenReturn(response); + + // when + ResultActions result = mockMvc.perform( + put("/inquiries/{inquiryId}", 1L) + .contentType(APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, COMPANY_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + .content(objectMapper.writeValueAsString(request)) + + ); + + // then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + pathParameters( + parameterWithName("inquiryId").description("수정할 문의 사항 ID") + ), + requestFields( + fieldWithPath("title").type(JsonFieldType.STRING).description("문의 사항 제목"), + fieldWithPath("content").type(JsonFieldType.STRING).description("문의 사항 내용") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("문의 사항 ID"), + fieldWithPath("authorName").type(JsonFieldType.STRING).description("문의 작성자 이름"), + fieldWithPath("projectId").type(JsonFieldType.NUMBER).description("문의 대상 프로젝트 ID"), + fieldWithPath("projectName").type(JsonFieldType.STRING).description("문의 대상 프로젝트 이름"), + fieldWithPath("title").type(JsonFieldType.STRING).description("수정된 문의 사항 제목"), + fieldWithPath("content").type(JsonFieldType.STRING).description("수정된 문의 사항 내용"), + fieldWithPath("replied").type(JsonFieldType.BOOLEAN).description("답변 등록 여부"), + fieldWithPath("createdAt").type(JsonFieldType.STRING).description("문의 사항 생성 시간"), + fieldWithPath("updatedAt").type(JsonFieldType.STRING).description("문의 사항 수정 시간") + ) + )); + } + + @Test + @DisplayName("프로젝트 문의를 삭제할 수 있다.") + void deleteInquiry() throws Exception { + + // given + doNothing().when(inquiryService).deleteInquiry(anyLong(), any(User.class)); + + + // when + ResultActions result = mockMvc.perform( + delete("/inquiries/{inquiryId}", 1L) + .contentType(APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, COMPANY_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + // then + result.andExpect(status().isNoContent()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + pathParameters( + parameterWithName("inquiryId").description("삭제할 문의 사항 ID") + ) + )); + + } + + @Test + @DisplayName("프로젝트 문의 답변을 생성할 수 있다.") + void createInquiryReply() throws Exception { + // given + InquiryReplyRequest request = new InquiryReplyRequest("문의 답변 제목", "문의 답변 내용"); + InquiryReplyResponse response = InquiryReplyResponse.of(1L, "문의 답변 제목", "문의 답변 내용"); + + + when(inquiryService.createInquiryReply(anyLong(), any(InquiryReplyRequest.class))).thenReturn(response); + + // when + ResultActions result = mockMvc.perform( + post("/inquiries/{inquiryId}/reply", 1L) + .contentType(APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, ADMIN_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + .content(objectMapper.writeValueAsString(request)) + + ); + + // then + result.andExpect(status().isCreated()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + pathParameters( + parameterWithName("inquiryId").description("답변할 문의 ID") + ), + requestFields( + fieldWithPath("title").type(JsonFieldType.STRING).description("답변 제목"), + fieldWithPath("content").type(JsonFieldType.STRING).description("답변 내용") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("답변 ID"), + fieldWithPath("title").type(JsonFieldType.STRING).description("답변 제목"), + fieldWithPath("content").type(JsonFieldType.STRING).description("답변 내용") + ) + )); + } + + @Test + @DisplayName("프로젝트 문의 답변을 조회할 수 있다.") + void getInquiryReply() throws Exception { + + // given + InquiryReplyResponse response = InquiryReplyResponse.of(1L, "문의 답변 제목", "문의 답변 내용"); + + when(inquiryService.getInquiryReply(anyLong(), any(User.class))).thenReturn(response); + + // when + ResultActions result = mockMvc.perform( + get("/inquiries/{inquiryId}/reply", 1L) + .contentType(APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, COMPANY_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + // then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + pathParameters( + parameterWithName("inquiryId").description("조회할 문의 ID") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("답변 ID"), + fieldWithPath("title").type(JsonFieldType.STRING).description("답변 제목"), + fieldWithPath("content").type(JsonFieldType.STRING).description("답변 내용") + ) + )); + } + + @Test + @DisplayName("프로젝트 문의 답변을 수정할 수 있다.") + void updateInquiryReply() throws Exception { + + // given + InquiryReplyRequest request = new InquiryReplyRequest("수정된 문의 답변 제목", "수정된 문의 답변 내용"); + InquiryReplyResponse response = InquiryReplyResponse.of(1L, "수정된 문의 답변 제목", "수정된 문의 답변 내용"); + + when(inquiryService.updateInquiryReply(anyLong(), any(InquiryReplyRequest.class))).thenReturn(response); + + // when + ResultActions result = mockMvc.perform( + put("/inquiries/{inquiryId}/reply", 1L) + .contentType(APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, ADMIN_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + .content(objectMapper.writeValueAsString(request)) + + ); + + // then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + pathParameters( + parameterWithName("inquiryId").description("수정할 답변 ID") + ), + requestFields( + fieldWithPath("title").type(JsonFieldType.STRING).description("답변 제목"), + fieldWithPath("content").type(JsonFieldType.STRING).description("답변 내용") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("답변 ID"), + fieldWithPath("title").type(JsonFieldType.STRING).description("수정된 답변 제목"), + fieldWithPath("content").type(JsonFieldType.STRING).description("수정된 답변 내용") + ) + )); + } + + @Test + @DisplayName("프로젝트 문의 답변을 삭제할 수 있다.") + void deleteInquiryReply() throws Exception { + + // given + doNothing().when(inquiryService).deleteInquiryReply(anyLong()); + + // when + ResultActions result = mockMvc.perform( + delete("/inquiries/{inquiryId}/reply", 1L) + .contentType(APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, ADMIN_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + // then + result.andExpect(status().isNoContent()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + pathParameters( + parameterWithName("inquiryId").description("삭제할 답변 ID") + ) + )); + } +} diff --git a/src/test/java/com/scg/stop/jobInfo/controller/JobInfoControllerTest.java b/src/test/java/com/scg/stop/jobInfo/controller/JobInfoControllerTest.java new file mode 100644 index 00000000..abd4e9d6 --- /dev/null +++ b/src/test/java/com/scg/stop/jobInfo/controller/JobInfoControllerTest.java @@ -0,0 +1,154 @@ +package com.scg.stop.jobInfo.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.scg.stop.configuration.AbstractControllerTest; +import com.scg.stop.jobInfo.dto.request.JobInfoRequest; +import com.scg.stop.jobInfo.dto.response.JobInfoResponse; +import com.scg.stop.jobInfo.service.JobInfoService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.web.servlet.ResultActions; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(JobInfoController.class) +@MockBean(JpaMetamodelMappingContext.class) +@AutoConfigureRestDocs +class JobInfoControllerTest extends AbstractControllerTest { + + @MockBean + private JobInfoService jobInfoService; + + @Autowired + private ObjectMapper objectMapper; + + @Test + @DisplayName("Job Info 리스트를 조회할 수 있다.") + void getJobInfos() throws Exception { + + // given + + JobInfoRequest jobInfoRequest = new JobInfoRequest( + "기업명 1", + List.of("고용 형태 1"), + "근무 지역 1", + "채용 포지션 1", + "채용 시점 1", + List.of("open") + ); + + JobInfoResponse jobInfoResponse1 = new JobInfoResponse( + "page", + "노션 object 아이디 1", + "기업명 1", + List.of("고용 형태 1"), + "근무 지역 1", + "채용 포지션 1", + "로고 url", + "연봉", + "웹사이트 url", + List.of("open"), + "채용 시점 1", + "redirect url" + + ); + + JobInfoResponse jobInfoResponse2 = new JobInfoResponse( + "page", + "노션 object 아이디 2", + "기업명 1", + List.of("고용 형태 1", "고용 형태 2"), + "근무 지역 2", + "채용 포지션 1, 채용 시점 2", + "로고 url", + "연봉", + "웹사이트 url", + List.of("open"), + "채용 시점 1", + "redirect url" + ); + + Page page = new PageImpl<>(List.of(jobInfoResponse1, jobInfoResponse2), PageRequest.of(0, 10), 2); + + when(jobInfoService.getJobInfos(any(JobInfoRequest.class), any(Pageable.class))).thenReturn(page); + + // when + ResultActions result = mockMvc.perform( + post("/jobInfos") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(jobInfoRequest)) + ); + + // then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + queryParameters( + parameterWithName("page").description("페이지 번호 [default: 0]").optional(), + parameterWithName("size").description("페이지 크기 [default: 10]").optional() + ), + requestFields( + fieldWithPath("company").type(JsonFieldType.STRING).description("기업명"), + fieldWithPath("jobTypes").type(JsonFieldType.ARRAY).description("고용 형태"), + fieldWithPath("region").type(JsonFieldType.STRING).description("근무 지역"), + fieldWithPath("position").type(JsonFieldType.STRING).description("채용 포지션"), + fieldWithPath("hiringTime").type(JsonFieldType.STRING).description("채용 시점"), + fieldWithPath("state").type(JsonFieldType.ARRAY).description("채용 상태") + ), + responseFields( + fieldWithPath("content[].object").type(JsonFieldType.STRING).description("object 종류"), + fieldWithPath("content[].id").type(JsonFieldType.STRING).description("노션 object 아이디"), + fieldWithPath("content[].company").type(JsonFieldType.STRING).description("기업명"), + fieldWithPath("content[].jobTypes").type(JsonFieldType.ARRAY).description("고용 형태"), + fieldWithPath("content[].region").type(JsonFieldType.STRING).description("근무 지역"), + fieldWithPath("content[].position").type(JsonFieldType.STRING).description("채용 포지션"), + fieldWithPath("content[].logo").type(JsonFieldType.STRING).description("로고 url"), + fieldWithPath("content[].salary").type(JsonFieldType.STRING).description("연봉"), + fieldWithPath("content[].website").type(JsonFieldType.STRING).description("웹사이트 url"), + fieldWithPath("content[].state").type(JsonFieldType.ARRAY).description("채용 상태"), + fieldWithPath("content[].hiringTime").type(JsonFieldType.STRING).description("채용 시점"), + fieldWithPath("content[].url").type(JsonFieldType.STRING).description("redirect url"), + fieldWithPath("pageable").type(JsonFieldType.OBJECT).description("페이지 정보"), + fieldWithPath("pageable.pageNumber").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("pageable.pageSize").type(JsonFieldType.NUMBER).description("페이지 당 요소 수"), + fieldWithPath("pageable.sort.empty").type(JsonFieldType.BOOLEAN).description("정렬 정보가 비어있는지 여부"), + fieldWithPath("pageable.sort.sorted").type(JsonFieldType.BOOLEAN).description("정렬된 상태인지 여부"), + fieldWithPath("pageable.sort.unsorted").type(JsonFieldType.BOOLEAN).description("정렬되지 않은 상태인지 여부"), + fieldWithPath("pageable.offset").type(JsonFieldType.NUMBER).description("오프셋"), + fieldWithPath("pageable.paged").type(JsonFieldType.BOOLEAN).description("페이징된 여부"), + fieldWithPath("pageable.unpaged").type(JsonFieldType.BOOLEAN).description("페이징되지 않은 여부"), + fieldWithPath("totalElements").type(JsonFieldType.NUMBER).description("전체 요소 수"), + fieldWithPath("totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), + fieldWithPath("size").type(JsonFieldType.NUMBER).description("페이지 당 요소 수"), + fieldWithPath("number").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("numberOfElements").type(JsonFieldType.NUMBER).description("현재 페이지 요소 수"), + fieldWithPath("first").type(JsonFieldType.BOOLEAN).description("첫 페이지 여부"), + fieldWithPath("last").type(JsonFieldType.BOOLEAN).description("마지막 페이지 여부"), + fieldWithPath("sort.empty").type(JsonFieldType.BOOLEAN).description("정렬 정보가 비어있는지 여부"), + fieldWithPath("sort.unsorted").type(JsonFieldType.BOOLEAN).description("정렬되지 않은 상태인지 여부"), + fieldWithPath("sort.sorted").type(JsonFieldType.BOOLEAN).description("정렬된 상태인지 여부"), + fieldWithPath("empty").type(JsonFieldType.BOOLEAN).description("비어있는 페이지 여부") + ) + )); + } + + +} diff --git a/src/test/java/com/scg/stop/notice/controller/NoticeControllerTest.java b/src/test/java/com/scg/stop/notice/controller/NoticeControllerTest.java new file mode 100644 index 00000000..91951c62 --- /dev/null +++ b/src/test/java/com/scg/stop/notice/controller/NoticeControllerTest.java @@ -0,0 +1,322 @@ +package com.scg.stop.notice.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.scg.stop.configuration.AbstractControllerTest; +import com.scg.stop.file.dto.response.FileResponse; +import com.scg.stop.notice.dto.request.NoticeRequest; +import com.scg.stop.notice.dto.response.NoticeListElementResponse; +import com.scg.stop.notice.dto.response.NoticeResponse; +import com.scg.stop.notice.service.NoticeService; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.http.HttpHeaders; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.web.servlet.ResultActions; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; +import static org.springframework.restdocs.cookies.CookieDocumentation.requestCookies; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(NoticeController.class) +@MockBean(JpaMetamodelMappingContext.class) +@AutoConfigureRestDocs +class NoticeControllerTest extends AbstractControllerTest { + + private static final String ACCESS_TOKEN = "admin_access_token"; + private static final String REFRESH_TOKEN = "refresh_token"; + + @MockBean + private NoticeService noticeService; + + @Autowired + private ObjectMapper objectMapper; + + @Test + @DisplayName("공지 사항을 생성할 수 있다.") + void createNotice() throws Exception { + + // given + NoticeRequest request = new NoticeRequest("공지 사항 제목", "공지 사항 내용", true, List.of(1L, 2L, 3L)); + + List files = Arrays.asList( + new FileResponse(1L, "014eb8a0-d4a6-11ee-adac-117d766aca1d", "예시 첨부 파일 1.jpg", "image/jpeg", LocalDateTime.now(), LocalDateTime.now()), + new FileResponse(2L, "11a480c0-13fa-11ef-9047-570191b390ea", "예시 첨부 파일 2.jpg", "image/jpeg", LocalDateTime.now(), LocalDateTime.now()), + new FileResponse(3L, "1883fc70-cfb4-11ee-a387-e754bd392d45", "예시 첨부 파일 3.jpg", "image/jpeg", LocalDateTime.now(), LocalDateTime.now()) + ); + NoticeResponse response = new NoticeResponse(1L, "공지 사항 제목", "공지 사항 내용", 0, true, LocalDateTime.now(), LocalDateTime.now(), files); + + when(noticeService.createNotice(any(NoticeRequest.class))).thenReturn(response); + + // when + ResultActions result = mockMvc.perform( + post("/notices") + .contentType(APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + .content(objectMapper.writeValueAsString(request)) + ); + + // then + result.andExpect(status().isCreated()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + requestFields( + fieldWithPath("title").type(JsonFieldType.STRING).description("공지 사항 제목"), + fieldWithPath("content").type(JsonFieldType.STRING).description("공지 사항 내용"), + fieldWithPath("fixed").type(JsonFieldType.BOOLEAN).description("공지 사항 고정 여부"), + fieldWithPath("fileIds").type(JsonFieldType.ARRAY).description("첨부 파일 ID 목록").optional() + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("공지 사항 ID"), + fieldWithPath("title").type(JsonFieldType.STRING).description("공지 사항 제목"), + fieldWithPath("content").type(JsonFieldType.STRING).description("공지 사항 내용"), + fieldWithPath("hitCount").type(JsonFieldType.NUMBER).description("공지 사항 조회수"), + fieldWithPath("fixed").type(JsonFieldType.BOOLEAN).description("공지 사항 고정 여부"), + fieldWithPath("createdAt").type(JsonFieldType.STRING).description("공지 사항 생성일"), + fieldWithPath("updatedAt").type(JsonFieldType.STRING).description("공지 사항 수정일"), + fieldWithPath("files").type(JsonFieldType.ARRAY).description("첨부 파일 목록"), + fieldWithPath("files[].id").type(JsonFieldType.NUMBER).description("파일 ID"), + fieldWithPath("files[].uuid").type(JsonFieldType.STRING).description("파일 고유 식별자"), + fieldWithPath("files[].name").type(JsonFieldType.STRING).description("파일 이름"), + fieldWithPath("files[].mimeType").type(JsonFieldType.STRING).description("파일 MIME 타입"), + fieldWithPath("files[].createdAt").type(JsonFieldType.STRING).description("파일 생성 시간"), + fieldWithPath("files[].updatedAt").type(JsonFieldType.STRING).description("파일 수정 시간") + ) + + )); + } + + @DisplayName("공지 사항 리스트를 조회할 수 있다.") + @Test + void getNoticeList() throws Exception { + + // given + NoticeListElementResponse notice1 = new NoticeListElementResponse(1L, "notice 1", 10, true, LocalDateTime.now(), LocalDateTime.now()); + NoticeListElementResponse notice2 = new NoticeListElementResponse(2L, "notice 2", 10, false, LocalDateTime.now(), LocalDateTime.now()); + Page page = new PageImpl<>(List.of(notice1, notice2), PageRequest.of(0, 10), 2); + + when(noticeService.getNoticeList(any(String.class), any(String.class), any(Pageable.class))).thenReturn(page); + + // when + ResultActions result = mockMvc.perform( + get("/notices") + .param("terms", "notice") + .param("scope", "title") + .param("page", "0") + .param("size", "10") + .contentType(APPLICATION_JSON) + ); + + // then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + queryParameters( + parameterWithName("terms").description("검색어 (optional)").optional(), + parameterWithName("scope").description("검색 범위 (title, content, both) [default: both, searchTerm=null 이면 null로 초기화]").optional(), + parameterWithName("page").description("페이지 번호 [default: 0]").optional(), + parameterWithName("size").description("페이지 크기 [default: 10]").optional() + ), + responseFields( + fieldWithPath("content[].id").type(JsonFieldType.NUMBER).description("공지 사항 ID"), + fieldWithPath("content[].title").type(JsonFieldType.STRING).description("공지 사항 제목"), + fieldWithPath("content[].hitCount").type(JsonFieldType.NUMBER).description("공지 사항 조회수"), + fieldWithPath("content[].fixed").type(JsonFieldType.BOOLEAN).description("공지 사항 고정 여부"), + fieldWithPath("content[].createdAt").type(JsonFieldType.STRING).description("공지 사항 생성일"), + fieldWithPath("content[].updatedAt").type(JsonFieldType.STRING).description("공지 사항 수정일"), + fieldWithPath("pageable").type(JsonFieldType.OBJECT).description("페이지 정보"), + fieldWithPath("pageable.pageNumber").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("pageable.pageSize").type(JsonFieldType.NUMBER).description("페이지 당 요소 수"), + fieldWithPath("pageable.sort.empty").type(JsonFieldType.BOOLEAN).description("정렬 정보가 비어있는지 여부"), + fieldWithPath("pageable.sort.sorted").type(JsonFieldType.BOOLEAN).description("정렬된 상태인지 여부"), + fieldWithPath("pageable.sort.unsorted").type(JsonFieldType.BOOLEAN).description("정렬되지 않은 상태인지 여부"), + fieldWithPath("pageable.offset").type(JsonFieldType.NUMBER).description("오프셋"), + fieldWithPath("pageable.paged").type(JsonFieldType.BOOLEAN).description("페이징된 여부"), + fieldWithPath("pageable.unpaged").type(JsonFieldType.BOOLEAN).description("페이징되지 않은 여부"), + fieldWithPath("totalElements").type(JsonFieldType.NUMBER).description("전체 요소 수"), + fieldWithPath("totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), + fieldWithPath("size").type(JsonFieldType.NUMBER).description("페이지 당 요소 수"), + fieldWithPath("number").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("numberOfElements").type(JsonFieldType.NUMBER).description("현재 페이지 요소 수"), + fieldWithPath("first").type(JsonFieldType.BOOLEAN).description("첫 페이지 여부"), + fieldWithPath("last").type(JsonFieldType.BOOLEAN).description("마지막 페이지 여부"), + fieldWithPath("sort.empty").type(JsonFieldType.BOOLEAN).description("정렬 정보가 비어있는지 여부"), + fieldWithPath("sort.unsorted").type(JsonFieldType.BOOLEAN).description("정렬되지 않은 상태인지 여부"), + fieldWithPath("sort.sorted").type(JsonFieldType.BOOLEAN).description("정렬된 상태인지 여부"), + fieldWithPath("empty").type(JsonFieldType.BOOLEAN).description("비어있는 페이지 여부") + ) + )); + } + + + @DisplayName("공지 사항을 조회할 수 있다.") + @Test + void getNotice() throws Exception { + + // given + List files = Arrays.asList( + new FileResponse(1L, "014eb8a0-d4a6-11ee-adac-117d766aca1d", "예시 첨부 파일 1.jpg", "image/jpeg", LocalDateTime.now(), LocalDateTime.now()), + new FileResponse(2L, "11a480c0-13fa-11ef-9047-570191b390ea", "예시 첨부 파일 2.jpg", "image/jpeg", LocalDateTime.now(), LocalDateTime.now()), + new FileResponse(3L, "1883fc70-cfb4-11ee-a387-e754bd392d45", "예시 첨부 파일 3.jpg", "image/jpeg", LocalDateTime.now(), LocalDateTime.now()) + ); + NoticeResponse response = new NoticeResponse(1L, "공지 사항 제목", "content", 10, true, LocalDateTime.now(), LocalDateTime.now(), files); + + when(noticeService.getNotice(anyLong())).thenReturn(response); + + // when + ResultActions result = mockMvc.perform( + get("/notices/{noticeId}", 1L) + .contentType(APPLICATION_JSON) + ); + + // then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + pathParameters( + parameterWithName("noticeId").description("조회할 공지 사항 ID") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("공지 사항 ID"), + fieldWithPath("title").type(JsonFieldType.STRING).description("공지 사항 제목"), + fieldWithPath("content").type(JsonFieldType.STRING).description("공지 사항 내용"), + fieldWithPath("hitCount").type(JsonFieldType.NUMBER).description("공지 사항 조회수"), + fieldWithPath("fixed").type(JsonFieldType.BOOLEAN).description("공지 사항 고정 여부"), + fieldWithPath("createdAt").type(JsonFieldType.STRING).description("공지 사항 생성일"), + fieldWithPath("updatedAt").type(JsonFieldType.STRING).description("공지 사항 수정일"), + fieldWithPath("files").type(JsonFieldType.ARRAY).description("첨부 파일 목록"), + fieldWithPath("files[].id").type(JsonFieldType.NUMBER).description("파일 ID"), + fieldWithPath("files[].uuid").type(JsonFieldType.STRING).description("파일 고유 식별자"), + fieldWithPath("files[].name").type(JsonFieldType.STRING).description("파일 이름"), + fieldWithPath("files[].mimeType").type(JsonFieldType.STRING).description("파일 MIME 타입"), + fieldWithPath("files[].createdAt").type(JsonFieldType.STRING).description("파일 생성 시간"), + fieldWithPath("files[].updatedAt").type(JsonFieldType.STRING).description("파일 수정 시간") + ) + + )); + } + + @DisplayName("공지 사항을 수정할 수 있다.") + @Test + void updateNotice() throws Exception { + + // given + NoticeRequest request = new NoticeRequest("수정된 공지 사항 제목", "수정된 공지 사항 내용", false, List.of(1L, 2L, 3L)); + List files = Arrays.asList( + new FileResponse(1L, "014eb8a0-d4a6-11ee-adac-117d766aca1d", "예시 첨부 파일 1.jpg", "image/jpeg", LocalDateTime.of(2024, 1, 1, 12, 0), LocalDateTime.now()), + new FileResponse(2L, "11a480c0-13fa-11ef-9047-570191b390ea", "예시 첨부 파일 2.jpg", "image/jpeg", LocalDateTime.of(2024, 1, 1, 12, 0), LocalDateTime.now()), + new FileResponse(3L, "1883fc70-cfb4-11ee-a387-e754bd392d45", "예시 첨부 파일 3.jpg", "image/jpeg", LocalDateTime.of(2024, 1, 1, 12, 0), LocalDateTime.now()) + ); + NoticeResponse response = new NoticeResponse(1L, "수정된 공지 사항 제목", "수정된 공지 사항 내용", 10, false, LocalDateTime.of(2024, 1, 1, 12, 0), LocalDateTime.now(), files); + + when(noticeService.updateNotice(anyLong(), any(NoticeRequest.class))).thenReturn(response); + + // when + ResultActions result = mockMvc.perform( + put("/notices/{noticeId}", 1L) + .contentType(APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + .content(objectMapper.writeValueAsString(request)) + ); + + // then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + pathParameters( + parameterWithName("noticeId").description("수정할 공지 사항 ID") + ), + requestFields( + fieldWithPath("title").type(JsonFieldType.STRING).description("공지 사항 제목"), + fieldWithPath("content").type(JsonFieldType.STRING).description("공지 사항 내용"), + fieldWithPath("fixed").type(JsonFieldType.BOOLEAN).description("공지 사항 고정 여부"), + fieldWithPath("fileIds").type(JsonFieldType.ARRAY).description("첨부 파일 ID 목록").optional() + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("공지 사항 ID"), + fieldWithPath("title").type(JsonFieldType.STRING).description("공지 사항 제목"), + fieldWithPath("content").type(JsonFieldType.STRING).description("공지 사항 내용"), + fieldWithPath("hitCount").type(JsonFieldType.NUMBER).description("공지 사항 조회수"), + fieldWithPath("fixed").type(JsonFieldType.BOOLEAN).description("공지 사항 고정 여부"), + fieldWithPath("createdAt").type(JsonFieldType.STRING).description("공지 사항 생성일"), + fieldWithPath("updatedAt").type(JsonFieldType.STRING).description("공지 사항 수정일"), + fieldWithPath("files").type(JsonFieldType.ARRAY).description("첨부 파일 목록"), + fieldWithPath("files[].id").type(JsonFieldType.NUMBER).description("파일 ID"), + fieldWithPath("files[].uuid").type(JsonFieldType.STRING).description("파일 고유 식별자"), + fieldWithPath("files[].name").type(JsonFieldType.STRING).description("파일 이름"), + fieldWithPath("files[].mimeType").type(JsonFieldType.STRING).description("파일 MIME 타입"), + fieldWithPath("files[].createdAt").type(JsonFieldType.STRING).description("파일 생성 시간"), + fieldWithPath("files[].updatedAt").type(JsonFieldType.STRING).description("파일 수정 시간") + ) + + )); + } + + + @DisplayName("공지 사항을 삭제할 수 있다.") + @Test + void deleteNotice() throws Exception { + + // given + doNothing().when(noticeService).deleteNotice(anyLong()); + + // when + ResultActions result = mockMvc.perform( + delete("/notices/{noticeId}", 1L) + .contentType(APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + // then + result.andExpect(status().isNoContent()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + pathParameters( + parameterWithName("noticeId").description("삭제할 공지 사항 ID") + ) + )); + } +} diff --git a/src/test/java/com/scg/stop/project/controller/ProjectControllerTest.java b/src/test/java/com/scg/stop/project/controller/ProjectControllerTest.java new file mode 100644 index 00000000..948cc9bc --- /dev/null +++ b/src/test/java/com/scg/stop/project/controller/ProjectControllerTest.java @@ -0,0 +1,868 @@ +package com.scg.stop.project.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.scg.stop.configuration.AbstractControllerTest; +import com.scg.stop.project.domain.AwardStatus; +import com.scg.stop.project.domain.ProjectCategory; +import com.scg.stop.project.domain.ProjectType; +import com.scg.stop.project.domain.Role; +import com.scg.stop.project.dto.request.CommentRequest; +import com.scg.stop.project.dto.request.MemberRequest; +import com.scg.stop.project.dto.request.ProjectRequest; +import com.scg.stop.project.dto.response.CommentResponse; +import com.scg.stop.project.dto.response.FileResponse; +import com.scg.stop.project.dto.response.ProjectDetailResponse; +import com.scg.stop.project.dto.response.ProjectResponse; +import com.scg.stop.project.service.ProjectService; +import com.scg.stop.user.domain.User; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.http.HttpHeaders; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.web.servlet.ResultActions; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; +import static org.springframework.restdocs.cookies.CookieDocumentation.requestCookies; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(ProjectController.class) +@MockBean(JpaMetamodelMappingContext.class) +@AutoConfigureRestDocs +public class ProjectControllerTest extends AbstractControllerTest { + + private static final String ALL_ACCESS_TOKEN = "all_access_token"; + private static final String OPTIONAL_ACCESS_TOKEN = "optional_access_token"; + private static final String ADMIN_ACCESS_TOKEN = "admin_access_token"; + private static final String REFRESH_TOKEN = "refresh_token"; + + @MockBean + private ProjectService projectService; + + @Autowired + private ObjectMapper objectMapper; + + @DisplayName("간단한 프로젝트 리스트를 조회한다. | 페이지네이션") + @Test + void getProjects() throws Exception { + // given + ProjectResponse projectResponse1 = new ProjectResponse( + 1L, + new FileResponse( + 1L, + "썸네일 uuid 1", + "썸네일 파일 이름 1", + "썸네일 mime 타입 1" + ), + "프로젝트 이름 1", + "팀 이름 1", + List.of("학생 이름 1", "학생 이름 2"), + List.of("교수 이름 1"), + ProjectType.STARTUP, + ProjectCategory.BIG_DATA_ANALYSIS, + AwardStatus.FIRST, + 2023, + 100, + false, + false, + "프로젝트 URL", + "프로젝트 설명" + ); + ProjectResponse projectResponse2 = new ProjectResponse( + 2L, + new FileResponse( + 2L, + "썸네일 uuid 2", + "썸네일 파일 이름 2", + "썸네일 mime 타입 2" + ), + "프로젝트 이름 2", + "팀 이름 2", + List.of("학생 이름 3", "학생 이름 4"), + List.of("교수 이름 2"), + ProjectType.LAB, + ProjectCategory.AI_MACHINE_LEARNING, + AwardStatus.SECOND, + 2023, + 100, + false, + true, + "프로젝트 URL", + "프로젝트 설명" + ); + Page pageResponse = new PageImpl<>(List.of(projectResponse1, projectResponse2), PageRequest.of(0, 10), 2); + + when(projectService.getProjects(any(), any(), any(), any(), any(), any())).thenReturn(pageResponse); + + // when + ResultActions result = mockMvc.perform( + get("/projects") + .contentType(APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, OPTIONAL_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + // then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + queryParameters( + parameterWithName("title").description("프로젝트 이름").optional(), + parameterWithName("year").description("프로젝트 년도").optional(), + parameterWithName("category").description("프로젝트 카테고리").optional(), + parameterWithName("type").description("프로젝트 타입").optional(), + parameterWithName("page").description("페이지 번호 [default: 0]").optional(), + parameterWithName("size").description("페이지 크기 [default: 10]").optional(), + parameterWithName("sort").description("정렬 기준").optional() + ), + responseFields( + fieldWithPath("content[].id").type(JsonFieldType.NUMBER).description("프로젝트 ID"), + fieldWithPath("content[].thumbnailInfo").type(JsonFieldType.OBJECT).description("썸네일 정보"), + fieldWithPath("content[].thumbnailInfo.id").type(JsonFieldType.NUMBER).description("썸네일 ID"), + fieldWithPath("content[].thumbnailInfo.uuid").type(JsonFieldType.STRING).description("썸네일 UUID"), + fieldWithPath("content[].thumbnailInfo.name").type(JsonFieldType.STRING).description("썸네일 파일 이름"), + fieldWithPath("content[].thumbnailInfo.mimeType").type(JsonFieldType.STRING).description("썸네일 MIME 타입"), + fieldWithPath("content[].projectName").type(JsonFieldType.STRING).description("프로젝트 이름"), + fieldWithPath("content[].teamName").type(JsonFieldType.STRING).description("팀 이름"), + fieldWithPath("content[].studentNames[]").type(JsonFieldType.ARRAY).description("학생 이름"), + fieldWithPath("content[].professorNames[]").type(JsonFieldType.ARRAY).description("교수 이름"), + fieldWithPath("content[].projectType").type(JsonFieldType.STRING).description("프로젝트 타입: RESEARCH_AND_BUSINESS_FOUNDATION, LAB, STARTUP, CLUB"), + fieldWithPath("content[].projectCategory").type(JsonFieldType.STRING).description("프로젝트 카테고리: COMPUTER_VISION, SYSTEM_NETWORK, WEB_APPLICATION, SECURITY_SOFTWARE_ENGINEERING, NATURAL_LANGUAGE_PROCESSING, BIG_DATA_ANALYSIS, AI_MACHINE_LEARNING, INTERACTION_AUGMENTED_REALITY"), + fieldWithPath("content[].awardStatus").type(JsonFieldType.STRING).description("수상 여부: NONE, FIRST, SECOND, THIRD, FOURTH, FIFTH"), + fieldWithPath("content[].year").type(JsonFieldType.NUMBER).description("프로젝트 년도"), + fieldWithPath("content[].likeCount").type(JsonFieldType.NUMBER).description("좋아요 수"), + fieldWithPath("content[].like").type(JsonFieldType.BOOLEAN).description("좋아요 여부"), + fieldWithPath("content[].bookMark").type(JsonFieldType.BOOLEAN).description("북마크 여부"), + fieldWithPath("content[].url").type(JsonFieldType.STRING).description("프로젝트 URL"), + fieldWithPath("content[].description").type(JsonFieldType.STRING).description("프로젝트 설명"), + fieldWithPath("pageable").type(JsonFieldType.OBJECT).description("페이지 정보"), + fieldWithPath("pageable.pageNumber").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("pageable.pageSize").type(JsonFieldType.NUMBER).description("페이지 당 요소 수"), + fieldWithPath("pageable.sort.empty").type(JsonFieldType.BOOLEAN).description("정렬 정보가 비어있는지 여부"), + fieldWithPath("pageable.sort.sorted").type(JsonFieldType.BOOLEAN).description("정렬된 상태인지 여부"), + fieldWithPath("pageable.sort.unsorted").type(JsonFieldType.BOOLEAN).description("정렬되지 않은 상태인지 여부"), + fieldWithPath("pageable.offset").type(JsonFieldType.NUMBER).description("오프셋"), + fieldWithPath("pageable.paged").type(JsonFieldType.BOOLEAN).description("페이징된 여부"), + fieldWithPath("pageable.unpaged").type(JsonFieldType.BOOLEAN).description("페이징되지 않은 여부"), + fieldWithPath("totalElements").type(JsonFieldType.NUMBER).description("전체 요소 수"), + fieldWithPath("totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), + fieldWithPath("size").type(JsonFieldType.NUMBER).description("페이지 당 요소 수"), + fieldWithPath("number").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("numberOfElements").type(JsonFieldType.NUMBER).description("현재 페이지 요소 수"), + fieldWithPath("first").type(JsonFieldType.BOOLEAN).description("첫 페이지 여부"), + fieldWithPath("last").type(JsonFieldType.BOOLEAN).description("마지막 페이지 여부"), + fieldWithPath("sort.empty").type(JsonFieldType.BOOLEAN).description("정렬 정보가 비어있는지 여부"), + fieldWithPath("sort.unsorted").type(JsonFieldType.BOOLEAN).description("정렬되지 않은 상태인지 여부"), + fieldWithPath("sort.sorted").type(JsonFieldType.BOOLEAN).description("정렬된 상태인지 여부"), + fieldWithPath("empty").type(JsonFieldType.BOOLEAN).description("비어있는 페이지 여부") + ) + )); + } + + @Test + @DisplayName("프로젝트를 생성한다.") + void createProject() throws Exception { + // given + List memberRequest = List.of( + new MemberRequest("학생 이름 1", Role.STUDENT), + new MemberRequest("학생 이름 2", Role.STUDENT), + new MemberRequest("교수 이름 1", Role.PROFESSOR) + ); + + ProjectRequest projectRequest = new ProjectRequest(1L, 2L, "프로젝트 이름", ProjectType.STARTUP, ProjectCategory.BIG_DATA_ANALYSIS, "팀 이름", "유튜브 ID", 2021, AwardStatus.NONE, memberRequest, "프로젝트 URL", "프로젝트 설명"); + + ProjectDetailResponse response = new ProjectDetailResponse( + 1L, + new FileResponse( + 2L, + "썸네일 uuid", + "썸네일 파일 이름", + "썸네일 mime 타입" + ), + new FileResponse( + 2L, + "포스터 uuid", + "포트서 파일 이름", + "포스터 mime 타입" + ), + "프로젝트 이름", + ProjectType.STARTUP, + ProjectCategory.BIG_DATA_ANALYSIS, + "팀 이름", + "유튜브 ID", + 2024, + AwardStatus.FIRST, + List.of("학생 이름 1", "학생 이름 2"), + List.of("교수 이름 1"), + 0, + false, + false, + List.of( + new CommentResponse(1L, 1L, "유저 이름", true, "댓글 내용", LocalDateTime.now(), LocalDateTime.now()), + new CommentResponse(2L, 1L, "유저 이름", false, "댓글 내용", LocalDateTime.now(), LocalDateTime.now()), + new CommentResponse(3L, 1L, "유저 이름", false, "댓글 내용", LocalDateTime.now(), LocalDateTime.now()) + ), + "프로젝트 URL", + "프로젝트 설명" + ); + + when(projectService.createProject(any(ProjectRequest.class), any(User.class))).thenReturn(response); + + // when + ResultActions result = mockMvc.perform( + post("/projects") + .contentType(APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, ADMIN_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + .content(objectMapper.writeValueAsString(projectRequest)) + ); + + // then + result.andExpect(status().isCreated()) + .andDo(restDocs.document( + requestFields( + fieldWithPath("thumbnailId").type(JsonFieldType.NUMBER).description("썸네일 ID"), + fieldWithPath("posterId").type(JsonFieldType.NUMBER).description("포스터 ID"), + fieldWithPath("projectName").type(JsonFieldType.STRING).description("프로젝트 이름"), + fieldWithPath("projectType").type(JsonFieldType.STRING).description("프로젝트 타입: RESEARCH_AND_BUSINESS_FOUNDATION, LAB, STARTUP, CLUB"), + fieldWithPath("projectCategory").type(JsonFieldType.STRING).description("프로젝트 카테고리: COMPUTER_VISION, SYSTEM_NETWORK, WEB_APPLICATION, SECURITY_SOFTWARE_ENGINEERING, NATURAL_LANGUAGE_PROCESSING, BIG_DATA_ANALYSIS, AI_MACHINE_LEARNING, INTERACTION_AUGMENTED_REALITY\n"), + fieldWithPath("teamName").type(JsonFieldType.STRING).description("팀 이름"), + fieldWithPath("youtubeId").type(JsonFieldType.STRING).description("프로젝트 youtubeId"), + fieldWithPath("year").type(JsonFieldType.NUMBER).description("프로젝트 년도"), + fieldWithPath("awardStatus").type(JsonFieldType.STRING).description("수상 여부: NONE, FIRST, SECOND, THIRD, FOURTH, FIFTH"), + fieldWithPath("members").type(JsonFieldType.ARRAY).description("멤버"), + fieldWithPath("members[].name").type(JsonFieldType.STRING).description("멤버 이름"), + fieldWithPath("members[].role").type(JsonFieldType.STRING).description("멤버 역할: STUDENT, PROFESSOR"), + fieldWithPath("url").type(JsonFieldType.STRING).description("프로젝트 URL"), + fieldWithPath("description").type(JsonFieldType.STRING).description("프로젝트 설명") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("프로젝트 ID"), + fieldWithPath("thumbnailInfo").type(JsonFieldType.OBJECT).description("썸네일 정보"), + fieldWithPath("thumbnailInfo.id").type(JsonFieldType.NUMBER).description("썸네일 ID"), + fieldWithPath("thumbnailInfo.uuid").type(JsonFieldType.STRING).description("썸네일 UUID"), + fieldWithPath("thumbnailInfo.name").type(JsonFieldType.STRING).description("썸네일 파일 이름"), + fieldWithPath("thumbnailInfo.mimeType").type(JsonFieldType.STRING).description("썸네일 MIME 타입"), + fieldWithPath("posterInfo").type(JsonFieldType.OBJECT).description("포스터 정보"), + fieldWithPath("posterInfo.id").type(JsonFieldType.NUMBER).description("포스터 ID"), + fieldWithPath("posterInfo.uuid").type(JsonFieldType.STRING).description("포스터 UUID"), + fieldWithPath("posterInfo.name").type(JsonFieldType.STRING).description("포스터 파일 이름"), + fieldWithPath("posterInfo.mimeType").type(JsonFieldType.STRING).description("포스터 MIME 타입"), + fieldWithPath("projectName").type(JsonFieldType.STRING).description("프로젝트 이름"), + fieldWithPath("projectType").type(JsonFieldType.STRING).description("프로젝트 타입: RESEARCH_AND_BUSINESS_FOUNDATION, LAB, STARTUP, CLUB"), + fieldWithPath("projectCategory").type(JsonFieldType.STRING).description("프로젝트 카테고리: COMPUTER_VISION, SYSTEM_NETWORK, WEB_APPLICATION, SECURITY_SOFTWARE_ENGINEERING, NATURAL_LANGUAGE_PROCESSING, BIG_DATA_ANALYSIS, AI_MACHINE_LEARNING, INTERACTION_AUGMENTED_REALITY"), + fieldWithPath("teamName").type(JsonFieldType.STRING).description("팀 이름"), + fieldWithPath("youtubeId").type(JsonFieldType.STRING).description("프로젝트 youtubeId"), + fieldWithPath("year").type(JsonFieldType.NUMBER).description("프로젝트 년도"), + fieldWithPath("awardStatus").type(JsonFieldType.STRING).description("수상 여부: NONE, FIRST, SECOND, THIRD, FOURTH, FIFTH "), + fieldWithPath("studentNames").type(JsonFieldType.ARRAY).description("학생 이름"), + fieldWithPath("professorNames").type(JsonFieldType.ARRAY).description("교수 이름"), + fieldWithPath("likeCount").type(JsonFieldType.NUMBER).description("좋아요 수"), + fieldWithPath("like").type(JsonFieldType.BOOLEAN).description("좋아요 여부"), + fieldWithPath("bookMark").type(JsonFieldType.BOOLEAN).description("북마크 여부"), + fieldWithPath("url").type(JsonFieldType.STRING).description("프로젝트 URL"), + fieldWithPath("description").type(JsonFieldType.STRING).description("프로젝트 설명"), + fieldWithPath("comments").type(JsonFieldType.ARRAY).description("댓글"), + fieldWithPath("comments[].id").type(JsonFieldType.NUMBER).description("댓글 ID"), + fieldWithPath("comments[].projectId").type(JsonFieldType.NUMBER).description("유저 ID"), + fieldWithPath("comments[].userName").type(JsonFieldType.STRING).description("유저 이름"), + fieldWithPath("comments[].isAnonymous").type(JsonFieldType.BOOLEAN).description("익명 여부"), + fieldWithPath("comments[].content").type(JsonFieldType.STRING).description("댓글 내용"), + fieldWithPath("comments[].createdAt").type(JsonFieldType.STRING).description("생성 시간"), + fieldWithPath("comments[].updatedAt").type(JsonFieldType.STRING).description("수정 시간") + ) + )); + } + + @Test + @DisplayName("상세 프로젝트 정보를 조회한다.") + void getProject() throws Exception { + // given + ProjectDetailResponse response = new ProjectDetailResponse( + 1L, + new FileResponse( + 2L, + "썸네일 uuid", + "썸네일 파일 이름", + "썸네일 mime 타입" + ), + new FileResponse( + 2L, + "포스터 uuid", + "포트서 파일 이름", + "포스터 mime 타입" + ), + "프로젝트 이름", + ProjectType.STARTUP, + ProjectCategory.BIG_DATA_ANALYSIS, + "팀 이름", + "유튜브 ID", + 2024, + AwardStatus.FIRST, + List.of("학생 이름 1", "학생 이름 2"), + List.of("교수 이름 1"), + 0, + false, + false, + List.of( + new CommentResponse(1L, 1L, "유저 이름", true, "댓글 내용", LocalDateTime.now(), LocalDateTime.now()), + new CommentResponse(2L, 1L, "유저 이름", false, "댓글 내용", LocalDateTime.now(), LocalDateTime.now()), + new CommentResponse(3L, 1L, "유저 이름", false, "댓글 내용", LocalDateTime.now(), LocalDateTime.now()) + ), + "프로젝트 URL", + "프로젝트 설명" + ); + + when(projectService.getProject(anyLong(), any(User.class))).thenReturn(response); + + // when + ResultActions result = mockMvc.perform( + get("/projects/{projectId}", 1L) + .contentType(APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, OPTIONAL_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + // then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + pathParameters( + parameterWithName("projectId").description("프로젝트 ID") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("프로젝트 ID"), + fieldWithPath("thumbnailInfo").type(JsonFieldType.OBJECT).description("썸네일 정보"), + fieldWithPath("thumbnailInfo.id").type(JsonFieldType.NUMBER).description("썸네일 ID"), + fieldWithPath("thumbnailInfo.uuid").type(JsonFieldType.STRING).description("썸네일 UUID"), + fieldWithPath("thumbnailInfo.name").type(JsonFieldType.STRING).description("썸네일 파일 이름"), + fieldWithPath("thumbnailInfo.mimeType").type(JsonFieldType.STRING).description("썸네일 MIME 타입"), + fieldWithPath("posterInfo").type(JsonFieldType.OBJECT).description("포스터 정보"), + fieldWithPath("posterInfo.id").type(JsonFieldType.NUMBER).description("포스터 ID"), + fieldWithPath("posterInfo.uuid").type(JsonFieldType.STRING).description("포스터 UUID"), + fieldWithPath("posterInfo.name").type(JsonFieldType.STRING).description("포스터 파일 이름"), + fieldWithPath("posterInfo.mimeType").type(JsonFieldType.STRING).description("포스터 MIME 타입"), + fieldWithPath("projectName").type(JsonFieldType.STRING).description("프로젝트 이름"), + fieldWithPath("projectType").type(JsonFieldType.STRING).description("프로젝트 타입: RESEARCH_AND_BUSINESS_FOUNDATION, LAB, STARTUP, CLUB"), + fieldWithPath("projectCategory").type(JsonFieldType.STRING).description("프로젝트 카테고리: COMPUTER_VISION, SYSTEM_NETWORK, WEB_APPLICATION, SECURITY_SOFTWARE_ENGINEERING, NATURAL_LANGUAGE_PROCESSING, BIG_DATA_ANALYSIS, AI_MACHINE_LEARNING, INTERACTION_AUGMENTED_REALITY"), + fieldWithPath("teamName").type(JsonFieldType.STRING).description("팀 이름"), + fieldWithPath("youtubeId").type(JsonFieldType.STRING).description("프로젝트 youtubeId"), + fieldWithPath("year").type(JsonFieldType.NUMBER).description("프로젝트 년도"), + fieldWithPath("awardStatus").type(JsonFieldType.STRING).description("수상 여부: NONE, FIRST, SECOND, THIRD, FOURTH, FIFTH "), + fieldWithPath("studentNames").type(JsonFieldType.ARRAY).description("학생 이름"), + fieldWithPath("professorNames").type(JsonFieldType.ARRAY).description("교수 이름"), + fieldWithPath("likeCount").type(JsonFieldType.NUMBER).description("좋아요 수"), + fieldWithPath("like").type(JsonFieldType.BOOLEAN).description("좋아요 여부"), + fieldWithPath("bookMark").type(JsonFieldType.BOOLEAN).description("북마크 여부"), + fieldWithPath("url").type(JsonFieldType.STRING).description("프로젝트 URL"), + fieldWithPath("description").type(JsonFieldType.STRING).description("프로젝트 설명"), + fieldWithPath("comments").type(JsonFieldType.ARRAY).description("댓글"), + fieldWithPath("comments[].id").type(JsonFieldType.NUMBER).description("댓글 ID"), + fieldWithPath("comments[].projectId").type(JsonFieldType.NUMBER).description("유저 ID"), + fieldWithPath("comments[].userName").type(JsonFieldType.STRING).description("유저 이름"), + fieldWithPath("comments[].isAnonymous").type(JsonFieldType.BOOLEAN).description("익명 여부"), + fieldWithPath("comments[].content").type(JsonFieldType.STRING).description("댓글 내용"), + fieldWithPath("comments[].createdAt").type(JsonFieldType.STRING).description("생성 시간"), + fieldWithPath("comments[].updatedAt").type(JsonFieldType.STRING).description("수정 시간") + ) + )); + } + + @Test + @DisplayName("상세 프로젝트 정보를 수정한다.") + void updateProject() throws Exception { + // given + List memberRequest = List.of( + new MemberRequest("학생 이름 3", Role.STUDENT), + new MemberRequest("학생 이름 4", Role.STUDENT), + new MemberRequest("교수 이름 2", Role.PROFESSOR) + ); + + ProjectRequest projectRequest = new ProjectRequest( + 3L, + 4L, + "프로젝트 이름", + ProjectType.LAB, + ProjectCategory.COMPUTER_VISION, + "팀 이름", + "유튜브 ID", + 2024, + AwardStatus.FIRST, + memberRequest, + "프로젝트 URL", + "프로젝트 설명" + ); + + ProjectDetailResponse response = new ProjectDetailResponse( + 1L, + new FileResponse( + 3L, + "썸네일 uuid", + "썸네일 파일 이름", + "썸네일 mime 타입" + ), + new FileResponse( + 4L, + "포스터 uuid", + "포트서 파일 이름", + "포스터 mime 타입" + ), + "프로젝트 이름", + ProjectType.LAB, + ProjectCategory.COMPUTER_VISION, + "팀 이름", + "유튜브 ID", + 2024, + AwardStatus.FIRST, + List.of("학생 이름 3", "학생 이름 4"), + List.of("교수 이름 2"), + 100, + false, + false, + List.of( + new CommentResponse(1L, 1L, "유저 이름", true, "댓글 내용", LocalDateTime.now(), LocalDateTime.now()), + new CommentResponse(2L, 1L, "유저 이름", false, "댓글 내용", LocalDateTime.now(), LocalDateTime.now()), + new CommentResponse(3L, 1L, "유저 이름", false, "댓글 내용", LocalDateTime.now(), LocalDateTime.now()) + ), + "프로젝트 URL", + "프로젝트 설명" + ); + + when(projectService.updateProject(anyLong(), any(ProjectRequest.class), any(User.class))).thenReturn(response); + + // when + ResultActions result = mockMvc.perform( + put("/projects/{projectId}", 1L) + .contentType(APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, ADMIN_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + .content(objectMapper.writeValueAsString(projectRequest)) + ); + + // then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + pathParameters( + parameterWithName("projectId").description("프로젝트 ID") + ), + requestFields( + fieldWithPath("thumbnailId").type(JsonFieldType.NUMBER).description("썸네일 ID"), + fieldWithPath("posterId").type(JsonFieldType.NUMBER).description("포스터 ID"), + fieldWithPath("projectName").type(JsonFieldType.STRING).description("프로젝트 이름"), + fieldWithPath("projectType").type(JsonFieldType.STRING).description("프로젝트 타입: RESEARCH_AND_BUSINESS_FOUNDATION, LAB, STARTUP, CLUB"), + fieldWithPath("projectCategory").type(JsonFieldType.STRING).description("프로젝트 카테고리: COMPUTER_VISION, SYSTEM_NETWORK, WEB_APPLICATION, SECURITY_SOFTWARE_ENGINEERING, NATURAL_LANGUAGE_PROCESSING, BIG_DATA_ANALYSIS, AI_MACHINE_LEARNING, INTERACTION_AUGMENTED_REALITY\n"), + fieldWithPath("teamName").type(JsonFieldType.STRING).description("팀 이름"), + fieldWithPath("youtubeId").type(JsonFieldType.STRING).description("프로젝트 youtubeId"), + fieldWithPath("year").type(JsonFieldType.NUMBER).description("프로젝트 년도"), + fieldWithPath("awardStatus").type(JsonFieldType.STRING).description("수상 여부: NONE, FIRST, SECOND, THIRD, FOURTH, FIFTH"), + fieldWithPath("members").type(JsonFieldType.ARRAY).description("멤버"), + fieldWithPath("members[].name").type(JsonFieldType.STRING).description("멤버 이름"), + fieldWithPath("members[].role").type(JsonFieldType.STRING).description("멤버 역할: STUDENT, PROFESSOR"), + fieldWithPath("url").type(JsonFieldType.STRING).description("프로젝트 URL"), + fieldWithPath("description").type(JsonFieldType.STRING).description("프로젝트 설명") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("프로젝트 ID"), + fieldWithPath("thumbnailInfo").type(JsonFieldType.OBJECT).description("썸네일 정보"), + fieldWithPath("thumbnailInfo.id").type(JsonFieldType.NUMBER).description("썸네일 ID"), + fieldWithPath("thumbnailInfo.uuid").type(JsonFieldType.STRING).description("썸네일 UUID"), + fieldWithPath("thumbnailInfo.name").type(JsonFieldType.STRING).description("썸네일 파일 이름"), + fieldWithPath("thumbnailInfo.mimeType").type(JsonFieldType.STRING).description("썸네일 MIME 타입"), + fieldWithPath("posterInfo").type(JsonFieldType.OBJECT).description("포스터 정보"), + fieldWithPath("posterInfo.id").type(JsonFieldType.NUMBER).description("포스터 ID"), + fieldWithPath("posterInfo.uuid").type(JsonFieldType.STRING).description("포스터 UUID"), + fieldWithPath("posterInfo.name").type(JsonFieldType.STRING).description("포스터 파일 이름"), + fieldWithPath("posterInfo.mimeType").type(JsonFieldType.STRING).description("포스터 MIME 타입"), + fieldWithPath("projectName").type(JsonFieldType.STRING).description("프로젝트 이름"), + fieldWithPath("projectType").type(JsonFieldType.STRING).description("프로젝트 타입: RESEARCH_AND_BUSINESS_FOUNDATION, LAB, STARTUP, CLUB"), + fieldWithPath("projectCategory").type(JsonFieldType.STRING).description("프로젝트 카테고리: COMPUTER_VISION, SYSTEM_NETWORK, WEB_APPLICATION, SECURITY_SOFTWARE_ENGINEERING, NATURAL_LANGUAGE_PROCESSING, BIG_DATA_ANALYSIS, AI_MACHINE_LEARNING, INTERACTION_AUGMENTED_REALITY"), + fieldWithPath("teamName").type(JsonFieldType.STRING).description("팀 이름"), + fieldWithPath("youtubeId").type(JsonFieldType.STRING).description("프로젝트 youtubeId"), + fieldWithPath("year").type(JsonFieldType.NUMBER).description("프로젝트 년도"), + fieldWithPath("awardStatus").type(JsonFieldType.STRING).description("수상 여부: NONE, FIRST, SECOND, THIRD, FOURTH, FIFTH "), + fieldWithPath("studentNames").type(JsonFieldType.ARRAY).description("학생 이름"), + fieldWithPath("professorNames").type(JsonFieldType.ARRAY).description("교수 이름"), + fieldWithPath("likeCount").type(JsonFieldType.NUMBER).description("좋아요 수"), + fieldWithPath("like").type(JsonFieldType.BOOLEAN).description("좋아요 여부"), + fieldWithPath("bookMark").type(JsonFieldType.BOOLEAN).description("북마크 여부"), + fieldWithPath("url").type(JsonFieldType.STRING).description("프로젝트 URL"), + fieldWithPath("description").type(JsonFieldType.STRING).description("프로젝트 설명"), + fieldWithPath("comments").type(JsonFieldType.ARRAY).description("댓글"), + fieldWithPath("comments[].id").type(JsonFieldType.NUMBER).description("댓글 ID"), + fieldWithPath("comments[].projectId").type(JsonFieldType.NUMBER).description("유저 ID"), + fieldWithPath("comments[].userName").type(JsonFieldType.STRING).description("유저 이름"), + fieldWithPath("comments[].isAnonymous").type(JsonFieldType.BOOLEAN).description("익명 여부"), + fieldWithPath("comments[].content").type(JsonFieldType.STRING).description("댓글 내용"), + fieldWithPath("comments[].createdAt").type(JsonFieldType.STRING).description("생성 시간"), + fieldWithPath("comments[].updatedAt").type(JsonFieldType.STRING).description("수정 시간") + ) + )); + } + + @Test + @DisplayName("프로젝트를 삭제한다.") + void deleteProject() throws Exception { + // given + doNothing().when(projectService).deleteProject(anyLong()); + + // when + ResultActions result = mockMvc.perform( + delete("/projects/{projectId}", 1L) + .contentType(APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, ADMIN_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + // then + result.andExpect(status().isNoContent()) + .andDo(restDocs.document( + pathParameters( + parameterWithName("projectId").description("프로젝트 ID") + ) + )); + } + + @Test + @DisplayName("관심 프로젝트를 추가한다.") + void createProjectFavorite() throws Exception { + // given + doNothing().when(projectService).createProjectFavorite(anyLong(), any(User.class)); + + // when + ResultActions result = mockMvc.perform( + post("/projects/{projectId}/favorite", 1L) + .contentType(APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, ALL_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + // then + result.andExpect(status().isCreated()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + pathParameters( + parameterWithName("projectId").description("프로젝트 ID") + ) + )); + } + + @Test + @DisplayName("관심 프로젝트를 삭제한다.") + void deleteProjectFavorite() throws Exception { + // given + doNothing().when(projectService).deleteProjectFavorite(anyLong(), any(User.class)); + + // when + ResultActions result = mockMvc.perform( + delete("/projects/{projectId}/favorite", 1L) + .contentType(APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, ALL_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + // then + result.andExpect(status().isNoContent()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + pathParameters( + parameterWithName("projectId").description("프로젝트 ID") + ) + )); + } + + @Test + @DisplayName("프로젝트 좋아요를 누른다.") + void createProjectLike() throws Exception { + // given + doNothing().when(projectService).createProjectLike(anyLong(), any(User.class)); + + // when + ResultActions result = mockMvc.perform( + post("/projects/{projectId}/like", 1L) + .contentType(APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, ALL_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + // then + result.andExpect(status().isCreated()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + pathParameters( + parameterWithName("projectId").description("프로젝트 ID") + ) + )); + } + + @Test + @DisplayName("프로젝트 좋아요를 취소한다.") + void deleteProjectLike() throws Exception { + // given + doNothing().when(projectService).deleteProjectLike(anyLong(), any(User.class)); + + // when + ResultActions result = mockMvc.perform( + delete("/projects/{projectId}/like", 1L) + .contentType(APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, ALL_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + // then + result.andExpect(status().isNoContent()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + pathParameters( + parameterWithName("projectId").description("프로젝트 ID") + ) + )); + } + + @Test + @DisplayName("프로젝트 댓글을 생성한다.") + void createProjectComment() throws Exception { + // given + CommentRequest commentRequest = new CommentRequest("댓글 내용", true); + + CommentResponse commentResponse = new CommentResponse(1L, 1L, "유저 이름", true, "댓글 내용", LocalDateTime.now(), LocalDateTime.now()); + + when(projectService.createProjectComment(anyLong(), any(User.class), any(CommentRequest.class))).thenReturn(commentResponse); + + // when + ResultActions result = mockMvc.perform( + post("/projects/{projectId}/comment", 1L) + .contentType(APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, ALL_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + .content(objectMapper.writeValueAsString(commentRequest)) + ); + + verify(projectService).createProjectComment( + anyLong(), // 첫 번째 인자 (프로젝트 ID) + any(User.class), // 두 번째 인자 (유저 ID) + any(CommentRequest.class) // 세 번째 인자 (댓글 요청 객체) + ); + + // then + result.andExpect(status().isCreated()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + pathParameters( + parameterWithName("projectId").description("프로젝트 ID") + ), + requestFields( + fieldWithPath("content").type(JsonFieldType.STRING).description("댓글 내용"), + fieldWithPath("isAnonymous").type(JsonFieldType.BOOLEAN).description("익명 여부") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("댓글 ID"), + fieldWithPath("projectId").type(JsonFieldType.NUMBER).description("프로젝트 ID"), + fieldWithPath("userName").type(JsonFieldType.STRING).description("유저 이름"), + fieldWithPath("isAnonymous").type(JsonFieldType.BOOLEAN).description("익명 여부"), + fieldWithPath("content").type(JsonFieldType.STRING).description("댓글 내용"), + fieldWithPath("createdAt").type(JsonFieldType.STRING).description("생성 시간"), + fieldWithPath("updatedAt").type(JsonFieldType.STRING).description("수정 시간") + ) + + )); + + + } + + @Test + @DisplayName("프로젝트 댓글을 삭제한다.") + void deleteProjectComment() throws Exception { + // given + doNothing().when(projectService).deleteProjectComment(anyLong(), anyLong(), any(User.class)); + + // when + ResultActions result = mockMvc.perform( + delete("/projects/{projectId}/comment/{commentId}", 1L, 1L) + .contentType(APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, ALL_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + // then + result.andExpect(status().isNoContent()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + pathParameters( + parameterWithName("projectId").description("프로젝트 ID"), + parameterWithName("commentId").description("댓글 ID") + ) + )); + } + + @Test + @DisplayName("해당년도에 수상한 프로젝트를 조회한다.") + void getAwardProjects() throws Exception { + // given + ProjectResponse projectResponse1 = new ProjectResponse( + 1L, + new FileResponse( + 1L, + "썸네일 uuid 1", + "썸네일 파일 이름 1", + "썸네일 mime 타입 1" + ), + "프로젝트 이름 1", + "팀 이름 1", + List.of("학생 이름 1", "학생 이름 2"), + List.of("교수 이름 1"), + ProjectType.STARTUP, + ProjectCategory.BIG_DATA_ANALYSIS, + AwardStatus.FIRST, + 2023, + 100, + false, + false, + "프로젝트 URL", + "프로젝트 설명" + ); + ProjectResponse projectResponse2 = new ProjectResponse( + 2L, + new FileResponse( + 2L, + "썸네일 uuid 2", + "썸네일 파일 이름 2", + "썸네일 mime 타입 2" + ), + "프로젝트 이름 2", + "팀 이름 2", + List.of("학생 이름 3", "학생 이름 4"), + List.of("교수 이름 2"), + ProjectType.LAB, + ProjectCategory.AI_MACHINE_LEARNING, + AwardStatus.SECOND, + 2023, + 100, + false, + true, + "프로젝트 URL", + "프로젝트 설명" + ); + Page pageResponse = new PageImpl<>(List.of(projectResponse1, projectResponse2), PageRequest.of(0, 10), 2); + + when(projectService.getAwardProjects(anyInt(), any(), any(User.class))).thenReturn(pageResponse); + + // when + ResultActions result = mockMvc.perform( + get("/projects/award?year={year}", 2024) + .contentType(APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, OPTIONAL_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + // then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + queryParameters( + parameterWithName("year").description("프로젝트 년도"), + parameterWithName("page").description("페이지 번호 [default: 0]").optional(), + parameterWithName("size").description("페이지 크기 [default: 10]").optional(), + parameterWithName("sort").description("정렬 기준").optional() + ), + responseFields( + fieldWithPath("content[].id").type(JsonFieldType.NUMBER).description("프로젝트 ID"), + fieldWithPath("content[].thumbnailInfo").type(JsonFieldType.OBJECT).description("썸네일 정보"), + fieldWithPath("content[].thumbnailInfo.id").type(JsonFieldType.NUMBER).description("썸네일 ID"), + fieldWithPath("content[].thumbnailInfo.uuid").type(JsonFieldType.STRING).description("썸네일 UUID"), + fieldWithPath("content[].thumbnailInfo.name").type(JsonFieldType.STRING).description("썸네일 파일 이름"), + fieldWithPath("content[].thumbnailInfo.mimeType").type(JsonFieldType.STRING).description("썸네일 MIME 타입"), + fieldWithPath("content[].projectName").type(JsonFieldType.STRING).description("프로젝트 이름"), + fieldWithPath("content[].teamName").type(JsonFieldType.STRING).description("팀 이름"), + fieldWithPath("content[].studentNames[]").type(JsonFieldType.ARRAY).description("학생 이름"), + fieldWithPath("content[].professorNames[]").type(JsonFieldType.ARRAY).description("교수 이름"), + fieldWithPath("content[].projectType").type(JsonFieldType.STRING).description("프로젝트 타입: RESEARCH_AND_BUSINESS_FOUNDATION, LAB, STARTUP, CLUB"), + fieldWithPath("content[].projectCategory").type(JsonFieldType.STRING).description("프로젝트 카테고리: COMPUTER_VISION, SYSTEM_NETWORK, WEB_APPLICATION, SECURITY_SOFTWARE_ENGINEERING, NATURAL_LANGUAGE_PROCESSING, BIG_DATA_ANALYSIS, AI_MACHINE_LEARNING, INTERACTION_AUGMENTED_REALITY"), + fieldWithPath("content[].awardStatus").type(JsonFieldType.STRING).description("수상 여부: NONE, FIRST, SECOND, THIRD, FOURTH, FIFTH"), + fieldWithPath("content[].year").type(JsonFieldType.NUMBER).description("프로젝트 년도"), + fieldWithPath("content[].likeCount").type(JsonFieldType.NUMBER).description("좋아요 수"), + fieldWithPath("content[].like").type(JsonFieldType.BOOLEAN).description("좋아요 여부"), + fieldWithPath("content[].bookMark").type(JsonFieldType.BOOLEAN).description("북마크 여부"), + fieldWithPath("content[].url").type(JsonFieldType.STRING).description("프로젝트 URL"), + fieldWithPath("content[].description").type(JsonFieldType.STRING).description("프로젝트 설명"), + fieldWithPath("pageable").type(JsonFieldType.OBJECT).description("페이지 정보"), + fieldWithPath("pageable.pageNumber").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("pageable.pageSize").type(JsonFieldType.NUMBER).description("페이지 당 요소 수"), + fieldWithPath("pageable.sort.empty").type(JsonFieldType.BOOLEAN).description("정렬 정보가 비어있는지 여부"), + fieldWithPath("pageable.sort.sorted").type(JsonFieldType.BOOLEAN).description("정렬된 상태인지 여부"), + fieldWithPath("pageable.sort.unsorted").type(JsonFieldType.BOOLEAN).description("정렬되지 않은 상태인지 여부"), + fieldWithPath("pageable.offset").type(JsonFieldType.NUMBER).description("오프셋"), + fieldWithPath("pageable.paged").type(JsonFieldType.BOOLEAN).description("페이징된 여부"), + fieldWithPath("pageable.unpaged").type(JsonFieldType.BOOLEAN).description("페이징되지 않은 여부"), + fieldWithPath("totalElements").type(JsonFieldType.NUMBER).description("전체 요소 수"), + fieldWithPath("totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), + fieldWithPath("size").type(JsonFieldType.NUMBER).description("페이지 당 요소 수"), + fieldWithPath("number").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("numberOfElements").type(JsonFieldType.NUMBER).description("현재 페이지 요소 수"), + fieldWithPath("first").type(JsonFieldType.BOOLEAN).description("첫 페이지 여부"), + fieldWithPath("last").type(JsonFieldType.BOOLEAN).description("마지막 페이지 여부"), + fieldWithPath("sort.empty").type(JsonFieldType.BOOLEAN).description("정렬 정보가 비어있는지 여부"), + fieldWithPath("sort.unsorted").type(JsonFieldType.BOOLEAN).description("정렬되지 않은 상태인지 여부"), + fieldWithPath("sort.sorted").type(JsonFieldType.BOOLEAN).description("정렬된 상태인지 여부"), + fieldWithPath("empty").type(JsonFieldType.BOOLEAN).description("비어있는 페이지 여부") + ) + )); + } +} diff --git a/src/test/java/com/scg/stop/project/controller/ProjectExcelControllerTest.java b/src/test/java/com/scg/stop/project/controller/ProjectExcelControllerTest.java new file mode 100644 index 00000000..67158592 --- /dev/null +++ b/src/test/java/com/scg/stop/project/controller/ProjectExcelControllerTest.java @@ -0,0 +1,112 @@ +package com.scg.stop.project.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.scg.stop.configuration.AbstractControllerTest; +import com.scg.stop.file.dto.response.FileResponse; +import com.scg.stop.project.dto.response.ProjectExcelResponse; +import com.scg.stop.project.service.ProjectExcelService; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.web.servlet.ResultActions; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.multipart; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.partWithName; +import static org.springframework.restdocs.request.RequestDocumentation.requestParts; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(ProjectExcelController.class) +@MockBean(JpaMetamodelMappingContext.class) +@AutoConfigureRestDocs +class ProjectExcelControllerTest extends AbstractControllerTest { + + @MockBean + private ProjectExcelService projectExcelService; + + @Autowired + private ObjectMapper objectMapper; + + private static final String ADMIN_ACCESS_TOKEN = "admin_access_token"; + private static final String REFRESH_TOKEN = "refresh_token"; + + @Test + @DisplayName("엑셀 양식으로 프로젝트를 일괄등록할 수 있다.") + void createProjectExcel() throws Exception { + // given + MockMultipartFile excelFile = new MockMultipartFile( + "excel", + "project_upload_form.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "[BINARY DATA]".getBytes(StandardCharsets.UTF_8) + ); + List thumbnails = Arrays.asList(mockImageFileResponse("썸네일1.png"), mockImageFileResponse("썸네일2.png")); + List posters = Arrays.asList(mockImageFileResponse("포스터1.png"), mockImageFileResponse("포스터2.png")); + MockMultipartFile thumbnailsFile = new MockMultipartFile( + "thumbnails", + "thumbnails.json", + "application/json", + objectMapper.writeValueAsString(thumbnails).getBytes(StandardCharsets.UTF_8) + ); + MockMultipartFile postersFile = new MockMultipartFile( + "posters", + "thumbnails.json", + "application/json", + objectMapper.writeValueAsString(posters).getBytes(StandardCharsets.UTF_8) + ); + ProjectExcelResponse projectExcelResponse = new ProjectExcelResponse(2); + when(projectExcelService.createProjectExcel(any(), any(), any())).thenReturn(projectExcelResponse); + + // when + ResultActions result = mockMvc.perform( + multipart("/projects/excel") + .file(excelFile) + .file(thumbnailsFile) + .file(postersFile) + .header(HttpHeaders.AUTHORIZATION, ADMIN_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + .contentType(MediaType.MULTIPART_FORM_DATA) + ); + + // then + result.andExpect(status().isCreated()) + .andDo(restDocs.document( + requestParts( + partWithName("excel").description("업로드할 Excel 파일"), + partWithName("thumbnails").description("썸네일 등록 응답 JSON 파일"), + partWithName("posters").description("포스터 등록 응답 JSON 파일") + ), + responseFields( + fieldWithPath("successCount").type(JsonFieldType.NUMBER).description("생성에 성공한 프로젝트 개수") + ) + )); + } + + private FileResponse mockImageFileResponse(String fileName) { + return new FileResponse( + 1L, + UUID.randomUUID().toString(), + fileName, + "image/png", + LocalDateTime.now(), + LocalDateTime.now()); + } +} diff --git a/src/test/java/com/scg/stop/proposal/controller/ProposalControllerTest.java b/src/test/java/com/scg/stop/proposal/controller/ProposalControllerTest.java new file mode 100644 index 00000000..101829c1 --- /dev/null +++ b/src/test/java/com/scg/stop/proposal/controller/ProposalControllerTest.java @@ -0,0 +1,543 @@ +package com.scg.stop.proposal.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; +import static org.springframework.restdocs.cookies.CookieDocumentation.requestCookies; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.put; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.scg.stop.configuration.AbstractControllerTest; +import com.scg.stop.file.dto.response.FileResponse; +import com.scg.stop.project.domain.ProjectType; +import com.scg.stop.proposal.domain.request.CreateProposalRequest; +import com.scg.stop.proposal.domain.request.ProposalReplyRequest; +import com.scg.stop.proposal.domain.response.ProposalDetailResponse; +import com.scg.stop.proposal.domain.response.ProposalReplyResponse; +import com.scg.stop.proposal.domain.response.ProposalResponse; +import com.scg.stop.proposal.service.ProposalService; +import com.scg.stop.user.domain.User; +import com.scg.stop.user.domain.UserType; +import jakarta.servlet.http.Cookie; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.web.servlet.ResultActions; + +@WebMvcTest(ProposalController.class) +@MockBean(JpaMetamodelMappingContext.class) +@AutoConfigureRestDocs +public class ProposalControllerTest extends AbstractControllerTest { + + private static final String ADMIN_ACCESS_TOKEN = "admin_access_token"; + private static final String ADMIN_OR_COMPANY_ACCESS_TOKEN = "admin_or_company_access_token"; + private static final String ALL_ACCESS_TOKEN = "all_user_access_token"; + private static final String REFRESH_TOKEN = "refresh_token"; + + @Autowired + ObjectMapper objectMapper; + + @MockBean + ProposalService proposalService; + + @BeforeEach + void setUp() { + User adminUser = new User("admin"); + adminUser.register( + "어드민", + "email.com", + "phone", + UserType.ADMIN, + "temp" + ); +// when(authUserArgumentResolver.supportsParameter(any())).thenReturn(true); + doCallRealMethod().when(authUserArgumentResolver).supportsParameter(any()); + when(authUserArgumentResolver.resolveArgument(any(), any(), any(), any())) + .thenReturn(adminUser); + } + @Test + @DisplayName("과제제안 리스트를 조회할 수 있다") + void getProposals() throws Exception { + //given + ProposalResponse proposal1 = ProposalResponse.of(1L, "과제 제안1", "이름", LocalDateTime.now()); + ProposalResponse proposal2 = ProposalResponse.of(2L, "과제 제안2", "이름", LocalDateTime.now()); + + Page proposalPage = new PageImpl<>( + List.of(proposal1, proposal2), + PageRequest.of(0, 10), 2 + ); + when(proposalService.getProposalList(any(), any(), any(), any())).thenReturn(proposalPage); + + //when + ResultActions result = mockMvc.perform(get( + "/proposals") + .header(HttpHeaders.AUTHORIZATION, ALL_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN))); + + //then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + queryParameters( + parameterWithName("scope").description("필터 조건 (title,content,both,author)").optional(), + parameterWithName("term").description("필터 키워드").optional(), + parameterWithName("page").description("페이지 번호 [default = 0]").optional(), + parameterWithName("size").description("페이지 크기 [default = 10]").optional() + ), + responseFields( + fieldWithPath("content[].id").type(JsonFieldType.NUMBER).description("과제 제안 ID"), + fieldWithPath("content[].title").type(JsonFieldType.STRING).description("과제 제안 제목"), + fieldWithPath("content[].name").type(JsonFieldType.STRING).description("과제 제안 작성자"), + fieldWithPath("content[].createdDate").type(JsonFieldType.STRING).description("과제 제안 생성일"), + fieldWithPath("pageable").type(JsonFieldType.OBJECT).description("페이지 정보"), + fieldWithPath("pageable.pageNumber").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("pageable.pageSize").type(JsonFieldType.NUMBER).description("페이지 당 요소 수"), + fieldWithPath("pageable.sort.empty").type(JsonFieldType.BOOLEAN).description("정렬 정보가 비어있는지 여부"), + fieldWithPath("pageable.sort.sorted").type(JsonFieldType.BOOLEAN).description("정렬된 상태인지 여부"), + fieldWithPath("pageable.sort.unsorted").type(JsonFieldType.BOOLEAN).description("정렬되지 않은 상태인지 여부"), + fieldWithPath("pageable.offset").type(JsonFieldType.NUMBER).description("오프셋"), + fieldWithPath("pageable.paged").type(JsonFieldType.BOOLEAN).description("페이징된 여부"), + fieldWithPath("pageable.unpaged").type(JsonFieldType.BOOLEAN).description("페이징되지 않은 여부"), + fieldWithPath("totalElements").type(JsonFieldType.NUMBER).description("전체 요소 수"), + fieldWithPath("totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), + fieldWithPath("size").type(JsonFieldType.NUMBER).description("페이지 당 요소 수"), + fieldWithPath("number").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("numberOfElements").type(JsonFieldType.NUMBER).description("현재 페이지 요소 수"), + fieldWithPath("first").type(JsonFieldType.BOOLEAN).description("첫 페이지 여부"), + fieldWithPath("last").type(JsonFieldType.BOOLEAN).description("마지막 페이지 여부"), + fieldWithPath("sort.empty").type(JsonFieldType.BOOLEAN).description("정렬 정보가 비어있는지 여부"), + fieldWithPath("sort.unsorted").type(JsonFieldType.BOOLEAN).description("정렬되지 않은 상태인지 여부"), + fieldWithPath("sort.sorted").type(JsonFieldType.BOOLEAN).description("정렬된 상태인지 여부"), + fieldWithPath("empty").type(JsonFieldType.BOOLEAN).description("비어있는 페이지 여부") + ) + )); + } + + @Test + @DisplayName("과제 제안의 상세 페이지를 조회할 수 있다.") + void getProposalDetail() throws Exception { + //given + List fileResponses = Arrays.asList( + new FileResponse(1L, "uuid1", "과제제안서 이름1.pdf", "application/pdf", LocalDateTime.now(), LocalDateTime.now()), + new FileResponse(2L, "uuid2", "과제제안서 이름2.pdf", "application/pdf", LocalDateTime.now(), LocalDateTime.now()), + new FileResponse(3L, "uuid3", "과제제안서 이름3.pdf", "application/pdf", LocalDateTime.now(), LocalDateTime.now()) + ); + + ProposalDetailResponse proposalDetailResponse = new ProposalDetailResponse(1L, + "작성자", + "작성자@email.com", + "website.com", + "과제 제안 제목", + List.of(ProjectType.LAB, ProjectType.CLUB, ProjectType.STARTUP, ProjectType.RESEARCH_AND_BUSINESS_FOUNDATION), + "과제제안 내용", + false, + fileResponses + ); + + when(proposalService.getProposalDetail(any(), any())).thenReturn(proposalDetailResponse); + + //when + ResultActions result = mockMvc.perform(get("/proposals/{proposalId}", 1L) + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, ADMIN_OR_COMPANY_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN))); + + //then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + pathParameters( + parameterWithName("proposalId").description("수정할 과제제안 ID") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("과제 제안 ID"), + fieldWithPath("authorName").type(JsonFieldType.STRING).description("과제 제안 작성자"), + fieldWithPath("email").type(JsonFieldType.STRING).description("과제 제안 이메일"), + fieldWithPath("webSite").type(JsonFieldType.STRING).description("과제 제안 사이트"), + fieldWithPath("title").type(JsonFieldType.STRING).description("과제 제안 제목"), + fieldWithPath("projectTypes").type(JsonFieldType.ARRAY).description("과제 제안 프로젝트 유형들"), + fieldWithPath("content").type(JsonFieldType.STRING).description("과제 제안 내용"), + fieldWithPath("replied").type(JsonFieldType.BOOLEAN).description("과제 제안 답변 유무"), + fieldWithPath("files").type(JsonFieldType.ARRAY).description("파일 목록"), + fieldWithPath("files[].id").type(JsonFieldType.NUMBER).description("파일 ID"), + fieldWithPath("files[].uuid").type(JsonFieldType.STRING).description("파일 UUID"), + fieldWithPath("files[].name").type(JsonFieldType.STRING).description("파일 이름"), + fieldWithPath("files[].mimeType").type(JsonFieldType.STRING).description("파일 MIME 타입"), + fieldWithPath("files[].createdAt").type(JsonFieldType.STRING).description("파일 생성일"), + fieldWithPath("files[].updatedAt").type(JsonFieldType.STRING).description("파일 수정일") + ) + )); + } + + @Test + @DisplayName("과제 제안을 생성할 수 있다.") + void createProposal() throws Exception { + CreateProposalRequest createProposalRequest = new CreateProposalRequest( + "website.com", + "과제제안제목", + "이메일@email.com", + List.of(ProjectType.LAB, ProjectType.CLUB), + "과제제안내용", + "true", "true", + List.of(1L, 2L, 3L) + ); + List fileResponses = Arrays.asList( + new FileResponse(1L, "uuid1", "과제제안서 이름1.pdf", "application/pdf", LocalDateTime.now(), LocalDateTime.now()), + new FileResponse(2L, "uuid2", "과제제안서 이름2.pdf", "application/pdf", LocalDateTime.now(), LocalDateTime.now()), + new FileResponse(3L, "uuid3", "과제제안서 이름3.pdf", "application/pdf", LocalDateTime.now(), LocalDateTime.now()) + ); + ProposalDetailResponse proposalDetailResponse = new ProposalDetailResponse( + 1L, + "작성자", + "이메일@email.com", + "website.com", + "과제제안제목", + List.of(ProjectType.LAB, ProjectType.CLUB), + "과제제안내용", + false, + fileResponses + ); + when(proposalService.createProposal(any(), any())).thenReturn(proposalDetailResponse); + + ResultActions result = mockMvc.perform(post("/proposals") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createProposalRequest)) + .header(HttpHeaders.AUTHORIZATION, ADMIN_OR_COMPANY_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + //then + result.andExpect(status().isCreated()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + requestFields( + fieldWithPath("title").type(JsonFieldType.STRING).description("과제제안 제목"), + fieldWithPath("content").type(JsonFieldType.STRING).description("과제제안 내용"), + fieldWithPath("webSite").type(JsonFieldType.STRING).description("과제제안 사이트").optional(), + fieldWithPath("projectTypes").type(JsonFieldType.ARRAY).description("과제제안 프로젝트 유형:" + + "RESEARCH_AND_BUSINESS_FOUNDATION, LAB, STARTUP, CLUB"), + fieldWithPath("email").type(JsonFieldType.STRING).description("과제제안 이메일"), + fieldWithPath("isVisible").type(JsonFieldType.BOOLEAN).description("공개 여부"), + fieldWithPath("isAnonymous").type(JsonFieldType.BOOLEAN).description("익명 여부"), + fieldWithPath("fileIds").type(JsonFieldType.ARRAY).description("파일 id 리스트") + + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("과제 제안 ID"), + fieldWithPath("authorName").type(JsonFieldType.STRING).description("과제 제안 작성자"), + fieldWithPath("email").type(JsonFieldType.STRING).description("과제 제안 이메일"), + fieldWithPath("webSite").type(JsonFieldType.STRING).description("과제 제안 사이트"), + fieldWithPath("title").type(JsonFieldType.STRING).description("과제 제안 제목"), + fieldWithPath("projectTypes").type(JsonFieldType.ARRAY).description("과제 제안 프로젝트 유형들"), + fieldWithPath("content").type(JsonFieldType.STRING).description("과제 제안 내용"), + fieldWithPath("replied").type(JsonFieldType.BOOLEAN).description("과제 제안 답변 유무"),fieldWithPath("files").type(JsonFieldType.ARRAY).description("파일 목록"), + fieldWithPath("files[].id").type(JsonFieldType.NUMBER).description("파일 ID"), + fieldWithPath("files[].uuid").type(JsonFieldType.STRING).description("파일 UUID"), + fieldWithPath("files[].name").type(JsonFieldType.STRING).description("파일 이름"), + fieldWithPath("files[].mimeType").type(JsonFieldType.STRING).description("파일 MIME 타입"), + fieldWithPath("files[].createdAt").type(JsonFieldType.STRING).description("파일 생성일"), + fieldWithPath("files[].updatedAt").type(JsonFieldType.STRING).description("파일 수정일") + ) + )); + } + + @Test + @DisplayName("과제 제안을 수정할 수 있다.") + void updateProposal() throws Exception{ + CreateProposalRequest updateProposalRequest = new CreateProposalRequest( + "website.com", + "과제제안제목", + "이메일@email.com", + List.of(ProjectType.LAB, ProjectType.CLUB), + "과제제안내용", + "true", + "true", + List.of(1L,2L,3L) + ); + List fileResponses = Arrays.asList( + new FileResponse(1L, "uuid1", "과제제안서 이름1.pdf", "application/pdf", LocalDateTime.now(), LocalDateTime.now()), + new FileResponse(2L, "uuid2", "과제제안서 이름2.pdf", "application/pdf", LocalDateTime.now(), LocalDateTime.now()), + new FileResponse(3L, "uuid3", "과제제안서 이름3.pdf", "application/pdf", LocalDateTime.now(), LocalDateTime.now()) + ); + ProposalDetailResponse proposalDetailResponse = new ProposalDetailResponse( + 1L, + "작성자", + "이메일@email.com", + "website.com", + "과제제안제목", + List.of(ProjectType.LAB, ProjectType.CLUB), + "과제제안내용", + false, + fileResponses + ); + when(proposalService.updateProposal(any(), any(), any())).thenReturn(proposalDetailResponse); + + ResultActions result = mockMvc.perform(put("/proposals/{proposalId}", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateProposalRequest)) + .header(HttpHeaders.AUTHORIZATION, ADMIN_OR_COMPANY_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + //then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + pathParameters( + parameterWithName("proposalId").description("수정할 과제제안 ID") + ), + requestFields( + fieldWithPath("title").type(JsonFieldType.STRING).description("과제제안 제목"), + fieldWithPath("content").type(JsonFieldType.STRING).description("과제제안 내용"), + fieldWithPath("webSite").type(JsonFieldType.STRING).description("과제제안 사이트").optional(), + fieldWithPath("projectTypes").type(JsonFieldType.ARRAY).description("과제제안 프로젝트 유형:" + "RESEARCH_AND_BUSINESS_FOUNDATION, LAB, STARTUP, CLUB"), + fieldWithPath("email").type(JsonFieldType.STRING).description("과제제안 이메일"), + fieldWithPath("isVisible").type(JsonFieldType.BOOLEAN).description("공개 여부"), + fieldWithPath("isAnonymous").type(JsonFieldType.BOOLEAN).description("익명 여부"), + fieldWithPath("fileIds").type(JsonFieldType.ARRAY).description("파일 id 리스트") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("과제 제안 ID"), + fieldWithPath("authorName").type(JsonFieldType.STRING).description("과제 제안 작성자"), + fieldWithPath("email").type(JsonFieldType.STRING).description("과제 제안 이메일"), + fieldWithPath("webSite").type(JsonFieldType.STRING).description("과제 제안 사이트"), + fieldWithPath("title").type(JsonFieldType.STRING).description("과제 제안 제목"), + fieldWithPath("projectTypes").type(JsonFieldType.ARRAY).description("과제 제안 프로젝트 유형들"), + fieldWithPath("content").type(JsonFieldType.STRING).description("과제 제안 내용"), + fieldWithPath("replied").type(JsonFieldType.BOOLEAN).description("과제 제안 답변 유무"), + fieldWithPath("files").type(JsonFieldType.ARRAY).description("파일 목록"), + fieldWithPath("files[].id").type(JsonFieldType.NUMBER).description("파일 ID"), + fieldWithPath("files[].uuid").type(JsonFieldType.STRING).description("파일 UUID"), + fieldWithPath("files[].name").type(JsonFieldType.STRING).description("파일 이름"), + fieldWithPath("files[].mimeType").type(JsonFieldType.STRING).description("파일 MIME 타입"), + fieldWithPath("files[].createdAt").type(JsonFieldType.STRING).description("파일 생성일"), + fieldWithPath("files[].updatedAt").type(JsonFieldType.STRING).description("파일 수정일") + ) + )); + } + + @Test + @DisplayName("과제 제안을 삭제 할 수 있다.") + void deleteProposal() throws Exception{ + //given + doNothing().when(proposalService).deleteProposal(any(), any()); + + // when + ResultActions result = mockMvc.perform( + delete("/proposals/{proposalId}", 1L) + .contentType(APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, ADMIN_OR_COMPANY_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + // then + result.andExpect(status().isNoContent()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + pathParameters( + parameterWithName("proposalId").description("삭제할 과제제안 ID") + ) + )); + } + + @Test + @DisplayName("과제 제안 답변을 작성할 수 있다.") + void createProposalReply() throws Exception{ + ProposalReplyRequest proposalReplyRequest = new ProposalReplyRequest("답변 제목", "답변 내용"); + ProposalReplyResponse proposalReplyResponse = ProposalReplyResponse.of(1L, "답변 제목", "답변 내용"); + + when(proposalService.createProposalReply(any(), any())).thenReturn(proposalReplyResponse); + + ResultActions result = mockMvc.perform(post("/proposals/{proposalId}/reply", 1L) + .contentType(APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, ADMIN_OR_COMPANY_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + .content(objectMapper.writeValueAsString(proposalReplyRequest)) + ); + + result.andExpect(status().isCreated()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + pathParameters( + parameterWithName("proposalId").description("해당 과제제안 ID") + ), + requestFields( + fieldWithPath("title").type(JsonFieldType.STRING).description("과제제안 답변 제목"), + fieldWithPath("content").type(JsonFieldType.STRING).description("과제제안 답변 내용") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("과제 제안 답변 ID"), + fieldWithPath("title").type(JsonFieldType.STRING).description("과제 제안 답변 제목"), + fieldWithPath("content").type(JsonFieldType.STRING).description("과제 제안 답변 내용") + ) + )); + } + + @Test + @DisplayName("과제 제안 답변을 수정할 수 있다.") + void updateProposalReply() throws Exception { + ProposalReplyRequest proposalReplyRequest = new ProposalReplyRequest("답변 제목", "답변 내용"); + ProposalReplyResponse proposalReplyResponse = ProposalReplyResponse.of(1L, "답변 제목", "답변 내용"); + + when(proposalService.updateProposalReply(any(), any())).thenReturn(proposalReplyResponse); + + ResultActions result = mockMvc.perform(put("/proposals/{proposalId}/reply/{replyId}", 1L, 1L) + .contentType(APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, ADMIN_OR_COMPANY_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + .content(objectMapper.writeValueAsString(proposalReplyRequest)) + ); + + result.andExpect(status().isOk()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + pathParameters( + parameterWithName("proposalId").description("해당 과제제안 ID"), + parameterWithName("replyId").description("과제 제안 답변 ID") + ), + requestFields( + fieldWithPath("title").type(JsonFieldType.STRING).description("과제제안 답변 제목"), + fieldWithPath("content").type(JsonFieldType.STRING).description("과제제안 답변 내용") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("과제 제안 답변 ID"), + fieldWithPath("title").type(JsonFieldType.STRING).description("과제 제안 답변 제목"), + fieldWithPath("content").type(JsonFieldType.STRING).description("과제 제안 답변 내용") + ) + )); + } + + @Test + @DisplayName("과제 제안 답변을 삭제할 수 있다.") + void deleteProposalReply() throws Exception{ + //given + doNothing().when(proposalService).deleteProposalReply(any()); + + // when + ResultActions result = mockMvc.perform( + delete("/proposals/{proposalId}/reply/{proposalReplyId}", 1L, 1L) + .contentType(APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, ADMIN_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + // then + result.andExpect(status().isNoContent()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + pathParameters( + parameterWithName("proposalId").description("해당 과제제안 ID"), + parameterWithName("proposalReplyId").description("삭제할 과제제안 답변 ID") + ) + )); + } + + @Test + @DisplayName("과제제안 답변을 조회할 수 있다.") + void getProposalReplies() throws Exception{ + //given + ProposalReplyResponse proposalReplyResponse = ProposalReplyResponse.of(1L, "과제제안 답변 제목", "과제제안 답변 내용"); + + when(proposalService.getProposalReply(any(), any())).thenReturn(proposalReplyResponse); + + // when + ResultActions result = mockMvc.perform( + get("/proposals/{proposalId}/reply", 1L) + .contentType(APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, ADMIN_OR_COMPANY_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + // then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + pathParameters( + parameterWithName("proposalId").description("해당 과제제안 ID") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("과제 제안 답변 ID"), + fieldWithPath("title").type(JsonFieldType.STRING).description("과제 제안 답변 제목"), + fieldWithPath("content").type(JsonFieldType.STRING).description("과제 제안 답변 내용") + ) + )); + } + + +} diff --git a/src/test/java/com/scg/stop/proposal/persistence/ProposalRepositoryCustomImplTest.java b/src/test/java/com/scg/stop/proposal/persistence/ProposalRepositoryCustomImplTest.java new file mode 100644 index 00000000..5be9152e --- /dev/null +++ b/src/test/java/com/scg/stop/proposal/persistence/ProposalRepositoryCustomImplTest.java @@ -0,0 +1,156 @@ +package com.scg.stop.proposal.persistence; + +import static com.scg.stop.proposal.domain.Proposal.createProposal; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.scg.stop.file.domain.File; +import com.scg.stop.global.config.JpaAuditingConfig; +import com.scg.stop.global.config.QueryDslConfig; +import com.scg.stop.proposal.domain.Proposal; +import com.scg.stop.proposal.repository.ProposalRepository; +import com.scg.stop.user.domain.User; +import com.scg.stop.user.domain.UserType; +import jakarta.persistence.EntityManager; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +@DataJpaTest +@Import({ QueryDslConfig.class, JpaAuditingConfig.class}) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Disabled +class ProposalRepositoryCustomImplTest { + + @Autowired + private ProposalRepository proposalRepository; + + @Autowired + private EntityManager em; + + @BeforeEach + void setUp() { + User user1 = new User("socialId1"); + User user2 = new User("socialId2"); + user1.register("user1","email.com","010", UserType.ADMIN, "source"); + user2.register("user2","email.com","010", UserType.ADMIN, "source"); + em.persist(user1); + em.persist(user2); + + List files = Arrays.asList( + File.of("uuid1", "name1", "application/pdf"), + File.of("uuid2", "name2", "application/pdf"), + File.of("uuid3", "name3", "application/pdf") + ); + Proposal proposal1 = createProposal(user1, "A", "LAB", "email.com", "website.com", "특정내용 있", true, true, files); + Proposal proposal2= createProposal(user1, "B", "LAB", "email.com", "website.com", "내용 없", true, true, files); + Proposal proposal3 = createProposal(user2, "C", "LAB", "email.com", "website.com", "A있없", true, true, files); + em.persist(proposal1); + em.persist(proposal2); + em.persist(proposal3); + + em.flush(); + em.clear(); + } + + @Test + @DisplayName("제목으로 필터링을 걸 수 있다.") + void filterByTitle() { + // given + Pageable pageable = PageRequest.of(0, 10); + String scope = "title"; + String term = "A"; + + // when + Page result = proposalRepository.filterProposals(scope, term, pageable); + + // then + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getTitle()).contains("A"); + } + + @Test + @DisplayName("내용으로 필터링을 걸 수 있다.") + void filterByContent() { + // given + Pageable pageable = PageRequest.of(0, 10); + String scope = "content"; + String term = "특정내용"; + + // when + Page result = proposalRepository.filterProposals(scope, term, pageable); + + // then + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getContent()).contains("특정내용"); + } + + @Test + @DisplayName("올바르지 않은 필터는 에러를 반환한다.") + void filterInvalidScope() { + // given + Pageable pageable = PageRequest.of(0, 10); + String scope = "invalid"; + String term = "content"; + + // when & then + assertThrows(InvalidDataAccessApiUsageException.class, () -> proposalRepository.filterProposals(scope, term, pageable)); + } + + @Test + @DisplayName("필터 결과가 없는 경우 빈 페이지를 반환한다.") + void noMatchingProposals() { + // given + Pageable pageable = PageRequest.of(0, 10); + String scope = "title"; + String term = "Nonexistent"; + + // when + Page result = proposalRepository.filterProposals(scope, term, pageable); + + // then + assertThat(result.getContent()).isEmpty(); + } + + @Test + @DisplayName("필터가 제목+내용 인 경우 필터링 할 수 있다.") + void filterByBoth() { + // given + Pageable pageable = PageRequest.of(0, 10); + String scope = "both"; + String term = "A"; + + // when + Page result = proposalRepository.filterProposals(scope, term, pageable); + + // then + assertThat(result).hasSize(2); + } + + @Test + @DisplayName("필터가 작성자인 경우 필터링 할 수 있다.") + void filterByAuthor() { + // given + Pageable pageable = PageRequest.of(0, 10); + String scope = "author"; + String term = "user2"; + + // when + Page result = proposalRepository.filterProposals(scope, term, pageable); + + // then + assertThat(result).hasSize(1); + assertThat(result.getContent().get(0).getUser().getName()).isEqualTo("user2"); + } +} \ No newline at end of file diff --git a/src/test/java/com/scg/stop/resources/org/springframework/restdocs/templates/request-fields.snippet b/src/test/java/com/scg/stop/resources/org/springframework/restdocs/templates/request-fields.snippet new file mode 100644 index 00000000..db06d6ba --- /dev/null +++ b/src/test/java/com/scg/stop/resources/org/springframework/restdocs/templates/request-fields.snippet @@ -0,0 +1,10 @@ +|=== +|필드명|타입|필수값|설명 + +{{#fields}} +|{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}} +|{{#tableCellContent}}`+{{type}}+`{{/tableCellContent}} +|{{#tableCellContent}}{{^optional}}true{{/optional}}{{/tableCellContent}} +|{{#tableCellContent}}{{description}}{{/tableCellContent}} +{{/fields}} +|=== \ No newline at end of file diff --git a/src/test/java/com/scg/stop/user/controller/ApplicationControllerTest.java b/src/test/java/com/scg/stop/user/controller/ApplicationControllerTest.java new file mode 100644 index 00000000..7126b211 --- /dev/null +++ b/src/test/java/com/scg/stop/user/controller/ApplicationControllerTest.java @@ -0,0 +1,217 @@ +package com.scg.stop.user.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.cookies.CookieDocumentation.requestCookies; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.scg.stop.configuration.AbstractControllerTest; +import com.scg.stop.user.domain.UserType; +import com.scg.stop.user.dto.response.ApplicationDetailResponse; +import com.scg.stop.user.dto.response.ApplicationListResponse; +import com.scg.stop.user.service.ApplicationService; +import jakarta.servlet.http.Cookie; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.http.HttpHeaders; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +@WebMvcTest(ApplicationController.class) +@MockBean(JpaMetamodelMappingContext.class) +@AutoConfigureRestDocs +class ApplicationControllerTest extends AbstractControllerTest { + + private static final String ACCESS_TOKEN = "admin_access_token"; + private static final String REFRESH_TOKEN = "refresh_token"; + + @MockBean + private ApplicationService applicationService; + + @Autowired + private ObjectMapper objectMapper; + @Autowired + private MockMvc mockMvc; + + @Test + @DisplayName("교수/기업관계자의 인증 신청 정보 리스트를 조회할 수 있다.") + void getApplications() throws Exception { + + // given + ApplicationListResponse response1 = new ApplicationListResponse(1L, "김영한", "배민", null, UserType.INACTIVE_COMPANY, LocalDateTime.now(), + LocalDateTime.now()); + ApplicationListResponse response2 = new ApplicationListResponse(2L, "김교수", "솦융대", "교수", UserType.INACTIVE_PROFESSOR, LocalDateTime.now(), + LocalDateTime.now()); + ApplicationListResponse response3 = new ApplicationListResponse(3L, "박교수", "정통대", "교수", UserType.INACTIVE_PROFESSOR, LocalDateTime.now(), + LocalDateTime.now()); + Page page = new PageImpl<>(List.of(response1, response2, response3), PageRequest.of(0, 10), 3); + + when(applicationService.getApplications(any())).thenReturn(page); + + // when + ResultActions result = mockMvc.perform( + get("/applications") + .header(HttpHeaders.AUTHORIZATION, ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + // then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + queryParameters( + parameterWithName("page").description("페이지 번호 [default: 0]").optional(), + parameterWithName("size").description("페이지 크기 [default: 10]").optional() + ), + responseFields( + // page + fieldWithPath("pageable").type(JsonFieldType.OBJECT).description("페이지 정보"), + fieldWithPath("pageable.pageNumber").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("pageable.pageSize").type(JsonFieldType.NUMBER).description("페이지 당 요소 수"), + fieldWithPath("pageable.sort.empty").type(JsonFieldType.BOOLEAN).description("정렬 정보가 비어있는지 여부"), + fieldWithPath("pageable.sort.sorted").type(JsonFieldType.BOOLEAN).description("정렬된 상태인지 여부"), + fieldWithPath("pageable.sort.unsorted").type(JsonFieldType.BOOLEAN).description("정렬되지 않은 상태인지 여부"), + fieldWithPath("pageable.offset").type(JsonFieldType.NUMBER).description("오프셋"), + fieldWithPath("pageable.paged").type(JsonFieldType.BOOLEAN).description("페이징된 여부"), + fieldWithPath("pageable.unpaged").type(JsonFieldType.BOOLEAN).description("페이징되지 않은 여부"), + fieldWithPath("totalElements").type(JsonFieldType.NUMBER).description("전체 요소 수"), + fieldWithPath("totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), + fieldWithPath("size").type(JsonFieldType.NUMBER).description("페이지 당 요소 수"), + fieldWithPath("number").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("numberOfElements").type(JsonFieldType.NUMBER).description("현재 페이지 요소 수"), + fieldWithPath("first").type(JsonFieldType.BOOLEAN).description("첫 페이지 여부"), + fieldWithPath("last").type(JsonFieldType.BOOLEAN).description("마지막 페이지 여부"), + fieldWithPath("sort.empty").type(JsonFieldType.BOOLEAN).description("정렬 정보가 비어있는지 여부"), + fieldWithPath("sort.unsorted").type(JsonFieldType.BOOLEAN).description("정렬되지 않은 상태인지 여부"), + fieldWithPath("sort.sorted").type(JsonFieldType.BOOLEAN).description("정렬된 상태인지 여부"), + fieldWithPath("empty").type(JsonFieldType.BOOLEAN).description("비어있는 페이지 여부"), + //content + fieldWithPath("content[].id").type(JsonFieldType.NUMBER).description("가입 신청 ID"), + fieldWithPath("content[].name").type(JsonFieldType.STRING).description("가입 신청자 이름"), + fieldWithPath("content[].division").type(JsonFieldType.STRING).description("소속").optional(), + fieldWithPath("content[].position").type(JsonFieldType.STRING).description("직책").optional(), + fieldWithPath("content[].userType").type(JsonFieldType.STRING).description("회원 유형 [INACTIVE_PROFESSOR, INACTIVE_COMPANY]"), + fieldWithPath("content[].createdAt").type(JsonFieldType.STRING).description("가입 신청 정보 생성일"), + fieldWithPath("content[].updatedAt").type(JsonFieldType.STRING).description("가입 신청 정보 수정일") + ) + )); + } + + @Test + @DisplayName("가입 신청 상세 정보를 조회할 수 있다.") + void getApplication() throws Exception { + + // given + Long applicationId = 1L; + ApplicationDetailResponse response = new ApplicationDetailResponse( + applicationId, "김영한", "010-1111-2222", "email@gmail.com", "배민", "CEO", UserType.INACTIVE_COMPANY, LocalDateTime.now(), LocalDateTime.now() + ); + + when(applicationService.getApplication(applicationId)).thenReturn(response); + + // when + ResultActions result = mockMvc.perform(get("/applications/{applicationId}", applicationId) + .header(HttpHeaders.AUTHORIZATION, ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + // then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + pathParameters( + parameterWithName("applicationId").description("가입 신청 ID") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("가입 신청 ID"), + fieldWithPath("name").type(JsonFieldType.STRING).description("가입 신청자 이름"), + fieldWithPath("phone").type(JsonFieldType.STRING).description("가입 신청자 전화번호"), + fieldWithPath("email").type(JsonFieldType.STRING).description("가입 신청자 이메일"), + fieldWithPath("division").type(JsonFieldType.STRING).description("소속").optional(), + fieldWithPath("position").type(JsonFieldType.STRING).description("직책").optional(), + fieldWithPath("userType").type(JsonFieldType.STRING).description("회원 유형 [INACTIVE_PROFESSOR, INACTIVE_COMPANY]"), + fieldWithPath("createdAt").type(JsonFieldType.STRING).description("가입 신청 정보 생성일"), + fieldWithPath("updatedAt").type(JsonFieldType.STRING).description("가입 신청 정보 수정일") + ) + )); + } + + @Test + @DisplayName("교수/기업 가입 허가") + void updateApplication() throws Exception { + + // given + Long applicationId = 1L; + ApplicationDetailResponse response = new ApplicationDetailResponse( + applicationId, "김영한", "010-1111-2222", "email@gmail.com", "배민", "CEO", UserType.COMPANY, LocalDateTime.now(), LocalDateTime.now() + ); + + when(applicationService.approveApplication(applicationId)).thenReturn(response); + + // when + ResultActions result = mockMvc.perform(patch("/applications/{applicationId}", applicationId) + .header(HttpHeaders.AUTHORIZATION, ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + // then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + pathParameters( + parameterWithName("applicationId").description("가입 신청 ID") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("가입 신청 ID"), + fieldWithPath("name").type(JsonFieldType.STRING).description("가입 신청자 이름"), + fieldWithPath("phone").type(JsonFieldType.STRING).description("가입 신청자 전화번호"), + fieldWithPath("email").type(JsonFieldType.STRING).description("가입 신청자 이메일"), + fieldWithPath("division").type(JsonFieldType.STRING).description("소속").optional(), + fieldWithPath("position").type(JsonFieldType.STRING).description("직책").optional(), + fieldWithPath("userType").type(JsonFieldType.STRING).description("회원 유형 [PROFESSOR, COMPANY]"), + fieldWithPath("createdAt").type(JsonFieldType.STRING).description("가입 신청 정보 생성일"), + fieldWithPath("updatedAt").type(JsonFieldType.STRING).description("가입 신청 정보 수정일") + ) + )); + } + + @Test + @DisplayName("교수/기업 가입 거절") + void rejectApplication() throws Exception { + + // given + doNothing().when(applicationService).rejectApplication(anyLong()); + + // when + ResultActions result = mockMvc.perform(delete("/applications/{applicationId}", 1L) + .header(HttpHeaders.AUTHORIZATION, ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + // then + result.andExpect(status().isNoContent()) + .andDo(restDocs.document( + pathParameters(parameterWithName("applicationId").description("가입 신청 ID")) + )); + } +} \ No newline at end of file diff --git a/src/test/java/com/scg/stop/user/controller/DepartmentControllerTest.java b/src/test/java/com/scg/stop/user/controller/DepartmentControllerTest.java new file mode 100644 index 00000000..d3ca4e23 --- /dev/null +++ b/src/test/java/com/scg/stop/user/controller/DepartmentControllerTest.java @@ -0,0 +1,64 @@ +package com.scg.stop.user.controller; + +import com.scg.stop.configuration.AbstractControllerTest; +import com.scg.stop.user.dto.response.DepartmentResponse; +import com.scg.stop.user.service.DepartmentService; +import com.scg.stop.user.service.UserService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.http.HttpHeaders; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import java.util.Arrays; +import java.util.List; + +import static org.mockito.Mockito.when; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = DepartmentController.class) +@MockBean(JpaMetamodelMappingContext.class) +@AutoConfigureRestDocs +class DepartmentControllerTest extends AbstractControllerTest { + + @MockBean + private DepartmentService departmentService; + + @Autowired + private MockMvc mockMvc; + + @Test + @DisplayName("학과 리스트를 조회할 수 있다.") + void getDepartments() throws Exception { + // given + List departmentResponses = Arrays.asList( + new DepartmentResponse(1L, "소프트웨어학과"), + new DepartmentResponse(2L, "학과2") + ); + when(departmentService.getDepartments()).thenReturn(departmentResponses); + + // when + ResultActions result = mockMvc.perform( + get("/departments") + .contentType(APPLICATION_JSON) + ); + + // then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + responseFields( + fieldWithPath("[].id").description("학과 ID"), + fieldWithPath("[].name").description("학과 이름") + ) + )); + } +} \ No newline at end of file diff --git a/src/test/java/com/scg/stop/user/controller/UserControllerTest.java b/src/test/java/com/scg/stop/user/controller/UserControllerTest.java new file mode 100644 index 00000000..cc8307ff --- /dev/null +++ b/src/test/java/com/scg/stop/user/controller/UserControllerTest.java @@ -0,0 +1,482 @@ +package com.scg.stop.user.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.scg.stop.configuration.AbstractControllerTest; +import com.scg.stop.project.domain.AwardStatus; +import com.scg.stop.project.domain.ProjectCategory; +import com.scg.stop.project.domain.ProjectType; +import com.scg.stop.project.dto.response.FileResponse; +import com.scg.stop.project.dto.response.ProjectResponse; +import com.scg.stop.user.domain.FavoriteType; +import com.scg.stop.user.domain.User; +import com.scg.stop.user.domain.UserType; +import com.scg.stop.user.dto.request.UserUpdateRequest; +import com.scg.stop.user.dto.response.FavoriteResponse; +import com.scg.stop.user.dto.response.UserInquiryResponse; +import com.scg.stop.user.dto.response.UserProposalResponse; +import com.scg.stop.user.dto.response.UserResponse; +import com.scg.stop.user.service.UserService; +import com.scg.stop.video.domain.JobInterviewCategory; +import com.scg.stop.video.domain.QuizInfo; +import com.scg.stop.video.dto.response.JobInterviewUserResponse; +import com.scg.stop.video.dto.response.QuizResponse; +import com.scg.stop.video.dto.response.TalkUserResponse; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; +import static org.springframework.restdocs.cookies.CookieDocumentation.requestCookies; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = UserController.class) +@MockBean(JpaMetamodelMappingContext.class) +@AutoConfigureRestDocs +class UserControllerTest extends AbstractControllerTest { + + private static final String ACCESS_TOKEN = "user_access_token"; + private static final String REFRESH_TOKEN = "refresh_token"; + + @MockBean + private UserService userService; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + @DisplayName("사용자 정보를 조회할 수 있다.") + void getMe() throws Exception { + // given + UserResponse response = new UserResponse( + 1L, + "이름", + "010-1234-5678", + "student@g.skku.edu", + UserType.STUDENT, + null, + null, + "2000123456", + "학과", + LocalDateTime.now(), + LocalDateTime.now() + ); + when(userService.getMe(any(User.class))).thenReturn(response); + + // when + ResultActions result = mockMvc.perform( + get("/users/me") + .header(HttpHeaders.AUTHORIZATION, ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + // then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("사용자 ID"), + fieldWithPath("name").type(JsonFieldType.STRING).description("사용자 이름"), + fieldWithPath("phone").type(JsonFieldType.STRING).description("사용자 전화번호"), + fieldWithPath("email").type(JsonFieldType.STRING).description("사용자 이메일"), + fieldWithPath("userType").type(JsonFieldType.STRING).description("사용자 유형"), + fieldWithPath("division").type(JsonFieldType.STRING).description("소속").optional(), + fieldWithPath("position").type(JsonFieldType.STRING).description("직책").optional(), + fieldWithPath("studentNumber").type(JsonFieldType.STRING).description("학번").optional(), + fieldWithPath("departmentName").type(JsonFieldType.STRING).description("학과 이름").optional(), + fieldWithPath("createdAt").type(JsonFieldType.STRING).description("생성일"), + fieldWithPath("updatedAt").type(JsonFieldType.STRING).description("수정일") + ) + )); + } + + @Test + @DisplayName("사용자 정보를 수정할 수 있다.") + void updateMe() throws Exception { + // given + UserUpdateRequest request = new UserUpdateRequest( + "이름", + "010-1234-5678", + "student@g.skku.edu", + null, + null, + "2000123456", + "학과" + ); + UserResponse response = new UserResponse( + 1L, + "이름", + "010-1234-5678", + "student@g.skku.edu", + UserType.STUDENT, + null, + null, + "2000123456", + "학과", + LocalDateTime.now(), + LocalDateTime.now() + ); + + when(userService.updateMe(any(User.class), any(UserUpdateRequest.class))).thenReturn(response); + + // when + ResultActions result = mockMvc.perform( + put("/users/me") + .header(HttpHeaders.AUTHORIZATION, ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + + // then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + requestFields( + fieldWithPath("name").type(JsonFieldType.STRING).description("이름"), + fieldWithPath("phoneNumber").type(JsonFieldType.STRING).description("전화번호"), + fieldWithPath("email").type(JsonFieldType.STRING).description("이메일"), + fieldWithPath("division").type(JsonFieldType.STRING).description("소속").optional(), + fieldWithPath("position").type(JsonFieldType.STRING).description("직책").optional(), + fieldWithPath("studentNumber").type(JsonFieldType.STRING).description("학번").optional(), + fieldWithPath("department").type(JsonFieldType.STRING).description("학과").optional() + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("사용자 ID"), + fieldWithPath("name").type(JsonFieldType.STRING).description("사용자 이름"), + fieldWithPath("phone").type(JsonFieldType.STRING).description("사용자 전화번호"), + fieldWithPath("email").type(JsonFieldType.STRING).description("사용자 이메일"), + fieldWithPath("userType").type(JsonFieldType.STRING).description("사용자 유형"), + fieldWithPath("division").type(JsonFieldType.STRING).description("소속").optional(), + fieldWithPath("position").type(JsonFieldType.STRING).description("직책").optional(), + fieldWithPath("studentNumber").type(JsonFieldType.STRING).description("학번").optional(), + fieldWithPath("departmentName").type(JsonFieldType.STRING).description("학과 이름").optional(), + fieldWithPath("createdAt").type(JsonFieldType.STRING).description("생성일"), + fieldWithPath("updatedAt").type(JsonFieldType.STRING).description("수정일") + ) + )); + } + + @Test + @DisplayName("사용자 계정을 삭제할 수 있다.") + void deleteMe() throws Exception { + // given + doNothing().when(userService).deleteMe(any(User.class)); + + // when + ResultActions result = mockMvc.perform( + delete("/users/me") + .header(HttpHeaders.AUTHORIZATION, ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + // then + result.andExpect(status().isNoContent()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ) + )); + } + + @Test + @DisplayName("로그인 유저의 프로젝트 문의 리스트를 조회할 수 있다.") + void getUserInquiries() throws Exception { + // given + List inquiryResponses = Arrays.asList( + new UserInquiryResponse(1L, "Title 1", 1L, LocalDateTime.now(), true), + new UserInquiryResponse(2L, "Title 2", 2L, LocalDateTime.now(), false) + ); + when(userService.getUserInquiries(any(User.class))).thenReturn(inquiryResponses); + + // when + ResultActions result = mockMvc.perform( + get("/users/inquiries") + .header(HttpHeaders.AUTHORIZATION, ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + // then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token").description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization").description("Access Token") + ), + responseFields( + fieldWithPath("[].id").description("문의 ID"), + fieldWithPath("[].title").description("문의 제목"), + fieldWithPath("[].projectId").description("프로젝트 ID"), + fieldWithPath("[].createdDate").description("문의 생성일"), + fieldWithPath("[].hasReply").description("답변 여부") + ) + )); + + } + + @Test + @DisplayName("로그인 유저의 과제 제안 리스트를 조회할 수 있다.") + void getUserProposals() throws Exception { + // given + List proposalResponses = Arrays.asList( + new UserProposalResponse(1L, "Title 1", LocalDateTime.now(), true), + new UserProposalResponse(2L, "Title 2", LocalDateTime.now(), false) + ); + when(userService.getUserProposals(any(User.class))).thenReturn(proposalResponses); + + // when + ResultActions result = mockMvc.perform( + get("/users/proposals") + .header(HttpHeaders.AUTHORIZATION, ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + // then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token").description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization").description("Access Token") + ), + responseFields( + fieldWithPath("[].id").description("과제 제안 ID"), + fieldWithPath("[].title").description("프로젝트명"), + fieldWithPath("[].createdDate").description("과제 제안 생성일"), + fieldWithPath("[].hasReply").description("답변 여부") + ) + )); + + } + + @Test + @DisplayName("유저의 관심 프로젝트를 조회할 수 있다.") + void getUserFavoriteProjects() throws Exception { + // given + List responses = Arrays.asList( + new ProjectResponse( + 1L, + new FileResponse( + 1L, + "썸네일 uuid 1", + "썸네일 파일 이름 1", + "썸네일 mime 타입 1" + ), + "프로젝트 이름 1", + "팀 이름 1", + List.of("학생 이름 1", "학생 이름 2"), + List.of("교수 이름 1"), + ProjectType.STARTUP, + ProjectCategory.BIG_DATA_ANALYSIS, + AwardStatus.FIRST, + 2023, + 100, + false, + false, + "프로젝트 URL", + "프로젝트 설명" + ), + new ProjectResponse( + 2L, + new FileResponse( + 2L, + "썸네일 uuid 2", + "썸네일 파일 이름 2", + "썸네일 mime 타입 2" + ), + "프로젝트 이름 2", + "팀 이름 2", + List.of("학생 이름 3", "학생 이름 4"), + List.of("교수 이름 2"), + ProjectType.LAB, + ProjectCategory.AI_MACHINE_LEARNING, + AwardStatus.SECOND, + 2023, + 100, + false, + true, + "프로젝트 URL", + "프로젝트 설명" + ) + ); + when(userService.getUserFavoriteProjects(any(User.class))).thenReturn(responses); + + // when + ResultActions result = mockMvc.perform( + get("/users/favorites/projects") + .header(HttpHeaders.AUTHORIZATION, ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + // then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token").description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization").description("Access Token") + ), + responseFields( + fieldWithPath("[].id").type(JsonFieldType.NUMBER).description("프로젝트 ID"), + fieldWithPath("[].thumbnailInfo").type(JsonFieldType.OBJECT).description("썸네일 정보"), + fieldWithPath("[].thumbnailInfo.id").type(JsonFieldType.NUMBER).description("썸네일 ID"), + fieldWithPath("[].thumbnailInfo.uuid").type(JsonFieldType.STRING).description("썸네일 UUID"), + fieldWithPath("[].thumbnailInfo.name").type(JsonFieldType.STRING).description("썸네일 파일 이름"), + fieldWithPath("[].thumbnailInfo.mimeType").type(JsonFieldType.STRING).description("썸네일 MIME 타입"), + fieldWithPath("[].projectName").type(JsonFieldType.STRING).description("프로젝트 이름"), + fieldWithPath("[].teamName").type(JsonFieldType.STRING).description("팀 이름"), + fieldWithPath("[].studentNames[]").type(JsonFieldType.ARRAY).description("학생 이름"), + fieldWithPath("[].professorNames[]").type(JsonFieldType.ARRAY).description("교수 이름"), + fieldWithPath("[].projectType").type(JsonFieldType.STRING).description("프로젝트 타입: RESEARCH_AND_BUSINESS_FOUNDATION, LAB, STARTUP, CLUB"), + fieldWithPath("[].projectCategory").type(JsonFieldType.STRING).description("프로젝트 카테고리: COMPUTER_VISION, SYSTEM_NETWORK, WEB_APPLICATION, SECURITY_SOFTWARE_ENGINEERING, NATURAL_LANGUAGE_PROCESSING, BIG_DATA_ANALYSIS, AI_MACHINE_LEARNING, INTERACTION_AUGMENTED_REALITY"), + fieldWithPath("[].awardStatus").type(JsonFieldType.STRING).description("수상 여부: NONE, FIRST, SECOND, THIRD, FOURTH, FIFTH"), + fieldWithPath("[].year").type(JsonFieldType.NUMBER).description("프로젝트 년도"), + fieldWithPath("[].likeCount").type(JsonFieldType.NUMBER).description("좋아요 수"), + fieldWithPath("[].like").type(JsonFieldType.BOOLEAN).description("좋아요 여부"), + fieldWithPath("[].bookMark").type(JsonFieldType.BOOLEAN).description("북마크 여부"), + fieldWithPath("[].url").type(JsonFieldType.STRING).description("프로젝트 URL"), + fieldWithPath("[].description").type(JsonFieldType.STRING).description("프로젝트 설명") + ) + )); + } + + @Test + @DisplayName("유저의 관심 대담영상을 조회할 수 있다.") + void getUserFavoriteTalks() throws Exception { + // given + QuizResponse quizResponse = new QuizResponse( + List.of( + new QuizInfo("질문1", 0, List.of("선지1","선지2")), + new QuizInfo("질문2", 0, List.of("선지1","선지2")) + ) + ); + List responses = Arrays.asList( + new TalkUserResponse(1L, "제목1", "유튜브 고유ID", 2024, "대담자 소속1","대담자 성명1" ,true, quizResponse,LocalDateTime.now(), LocalDateTime.now()), + new TalkUserResponse(2L, "제목2", "유튜브 고유ID", 2024, "대담자 소속2","대담자 성명2" ,true, quizResponse,LocalDateTime.now(), LocalDateTime.now()) + ); + when(userService.getUserFavoriteTalks(any(User.class))).thenReturn(responses); + + // when + ResultActions result = mockMvc.perform( + get("/users/favorites/talks") + .header(HttpHeaders.AUTHORIZATION, ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + // then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token").description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization").description("Access Token") + ), + responseFields( + fieldWithPath("[].id").type(JsonFieldType.NUMBER).description("대담 영상 ID"), + fieldWithPath("[].title").type(JsonFieldType.STRING).description("대담 영상 제목"), + fieldWithPath("[].youtubeId").type(JsonFieldType.STRING).description("유튜브 영상의 고유 ID"), + fieldWithPath("[].year").type(JsonFieldType.NUMBER).description("대담 영상 연도"), + fieldWithPath("[].talkerBelonging").type(JsonFieldType.STRING).description("대담자의 소속된 직장/단체"), + fieldWithPath("[].talkerName").type(JsonFieldType.STRING).description("대담자의 성명"), + fieldWithPath("[].favorite").type(JsonFieldType.BOOLEAN).description("관심한 대담영상의 여부"), + fieldWithPath("[].quiz").type(JsonFieldType.ARRAY).description("퀴즈 데이터, 없는경우 null").optional(), + fieldWithPath("[].quiz[].question").type(JsonFieldType.STRING).description("퀴즈 1개의 질문").optional(), + fieldWithPath("[].quiz[].answer").type(JsonFieldType.NUMBER).description("퀴즈 1개의 정답선지 인덱스").optional(), + fieldWithPath("[].quiz[].options").type(JsonFieldType.ARRAY).description("퀴즈 1개의 정답선지 리스트").optional(), + fieldWithPath("[].createdAt").type(JsonFieldType.STRING).description("대담 영상 생성일"), + fieldWithPath("[].updatedAt").type(JsonFieldType.STRING).description("대담 영상 수정일") + ) + )); + } + + @Test + @DisplayName("유저의 관심 잡페어 영상을 조회할 수 있다.") + void getUserFavoriteInterviews() throws Exception { + // given + List responses = Arrays.asList( + new JobInterviewUserResponse(1L,"잡페어 인터뷰의 제목1", "유튜브 고유 ID1", 2023,"대담자의 소속1", "대담자의 성명1", false, JobInterviewCategory.INTERN, LocalDateTime.now(), LocalDateTime.now()), + new JobInterviewUserResponse(2L, "잡페어 인터뷰의 제목2", "유튜브 고유 ID2", 2024,"대담자의 소속2", "대담자의 성명2", true, JobInterviewCategory.INTERN, LocalDateTime.now(), LocalDateTime.now()) + ); + when(userService.getUserFavoriteInterviews(any(User.class))).thenReturn(responses); + + // when + ResultActions result = mockMvc.perform( + get("/users/favorites/jobInterviews") + .header(HttpHeaders.AUTHORIZATION, ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + // then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token").description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization").description("Access Token") + ), + responseFields( + fieldWithPath("[].id").type(JsonFieldType.NUMBER).description("잡페어 인터뷰 ID"), + fieldWithPath("[].title").type(JsonFieldType.STRING).description("잡페어 인터뷰 제목"), + fieldWithPath("[].youtubeId").type(JsonFieldType.STRING).description("유튜브 영상의 고유 ID"), + fieldWithPath("[].year").type(JsonFieldType.NUMBER).description("잡페어 인터뷰 연도"), + fieldWithPath("[].talkerBelonging").type(JsonFieldType.STRING).description("잡페어 인터뷰 대담자의 소속"), + fieldWithPath("[].talkerName").type(JsonFieldType.STRING).description("잡페어 인터뷰 대담자의 성명"), + fieldWithPath("[].favorite").type(JsonFieldType.BOOLEAN).description("관심에 추가한 잡페어 인터뷰 여부"), + fieldWithPath("[].category").type(JsonFieldType.STRING).description("잡페어 인터뷰 카테고리: SENIOR, INTERN"), + fieldWithPath("[].createdAt").type(JsonFieldType.STRING).description("잡페어 인터뷰 생성일"), + fieldWithPath("[].updatedAt").type(JsonFieldType.STRING).description("잡페어 인터뷰 수정일") + ) + )); + } + +} diff --git a/src/test/java/com/scg/stop/video/controller/JobInterviewControllerTest.java b/src/test/java/com/scg/stop/video/controller/JobInterviewControllerTest.java new file mode 100644 index 00000000..012840f5 --- /dev/null +++ b/src/test/java/com/scg/stop/video/controller/JobInterviewControllerTest.java @@ -0,0 +1,386 @@ +package com.scg.stop.video.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.scg.stop.configuration.AbstractControllerTest; +import com.scg.stop.video.controller.JobInterviewController; +import com.scg.stop.video.domain.JobInterviewCategory; +import com.scg.stop.video.dto.request.JobInterviewRequest; +import com.scg.stop.video.dto.response.JobInterviewResponse; +import com.scg.stop.video.dto.response.JobInterviewUserResponse; +import com.scg.stop.video.service.FavoriteVideoService; +import com.scg.stop.video.service.JobInterviewService; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.web.servlet.ResultActions; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; +import static org.springframework.restdocs.cookies.CookieDocumentation.requestCookies; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.put; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(JobInterviewController.class) +@MockBean(JpaMetamodelMappingContext.class) +@AutoConfigureRestDocs +public class JobInterviewControllerTest extends AbstractControllerTest { + + private static final String ACCESS_TOKEN = "admin_access_token"; + private static final String USER_ACCESS_TOKEN = "access_token"; + private static final String OPTIONAL_ACCESS_TOKEN = "optional_access_token"; + private static final String REFRESH_TOKEN = "refresh_token"; + + @MockBean + private JobInterviewService jobInterviewService; + + @MockBean + private FavoriteVideoService favoriteVideoService; + + @Autowired + private ObjectMapper objectMapper; + + @Test + @DisplayName("잡페어 인터뷰를 생성한다.") + void createJobInterview() throws Exception { + + // given + JobInterviewRequest request = new JobInterviewRequest("잡페어 인터뷰의 제목", "유튜브 고유 ID", 2024, "대담자의 소속", "대담자의 성명", JobInterviewCategory.INTERN); + JobInterviewResponse response = new JobInterviewResponse(1L, "잡페어 인터뷰의 제목", "유튜브 고유 ID", 2024,"대담자의 소속", "대담자의 성명", JobInterviewCategory.INTERN, LocalDateTime.now(), LocalDateTime.now()); + + when(jobInterviewService.createJobInterview(any())).thenReturn(response); + // when + ResultActions result = mockMvc.perform( + RestDocumentationRequestBuilders.post("/jobInterviews") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + .content(objectMapper.writeValueAsString(request)) + + ); + + // then + + result.andExpect(status().isCreated()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + requestFields( + fieldWithPath("title").type(JsonFieldType.STRING).description("잡페어 인터뷰 제목"), + fieldWithPath("youtubeId").type(JsonFieldType.STRING).description("유튜브 영상의 고유 ID"), + fieldWithPath("year").type(JsonFieldType.NUMBER).description("잡페어 인터뷰 연도"), + fieldWithPath("talkerBelonging").type(JsonFieldType.STRING).description("잡페어 인터뷰 대담자의 소속"), + fieldWithPath("talkerName").type(JsonFieldType.STRING).description("잡페어 인터뷰 대담자의 성명"), + fieldWithPath("category").type(JsonFieldType.STRING).description("잡페어 인터뷰 카테고리: SENIOR, INTERN") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("잡페어 인터뷰 ID"), + fieldWithPath("title").type(JsonFieldType.STRING).description("잡페어 인터뷰 제목"), + fieldWithPath("youtubeId").type(JsonFieldType.STRING).description("유튜브 영상의 고유 ID"), + fieldWithPath("year").type(JsonFieldType.NUMBER).description("잡페어 인터뷰 연도"), + fieldWithPath("talkerBelonging").type(JsonFieldType.STRING).description("잡페어 인터뷰 대담자의 소속"), + fieldWithPath("talkerName").type(JsonFieldType.STRING).description("잡페어 인터뷰 대담자의 성명"), + fieldWithPath("category").type(JsonFieldType.STRING).description("잡페어 인터뷰 카테고리: SENIOR, INTERN"), + fieldWithPath("createdAt").type(JsonFieldType.STRING).description("잡페어 인터뷰 생성일"), + fieldWithPath("updatedAt").type(JsonFieldType.STRING).description("잡페어 인터뷰 수정일") + ) + )); + } + + @Test + @DisplayName("잡페어 인터뷰 리스트를 조회할 수 있다.") + void getJobInterviewList() throws Exception { + //given + JobInterviewUserResponse interview1 = new JobInterviewUserResponse(1L,"잡페어 인터뷰의 제목1", "유튜브 고유 ID1", 2023,"대담자의 소속1", "대담자의 성명1", false, JobInterviewCategory.INTERN, LocalDateTime.now(), LocalDateTime.now()); + JobInterviewUserResponse interview2 = new JobInterviewUserResponse(2L, "잡페어 인터뷰의 제목2", "유튜브 고유 ID2", 2024,"대담자의 소속2", "대담자의 성명2", true, JobInterviewCategory.INTERN, LocalDateTime.now(), LocalDateTime.now()); + Page page = new PageImpl<>(List.of(interview1, interview2), PageRequest.of(0,10),2); + + when(jobInterviewService.getJobInterviews(any(), any(), any(), any(), any())).thenReturn(page); + + //when + ResultActions result = mockMvc.perform( + RestDocumentationRequestBuilders.get("/jobInterviews") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, OPTIONAL_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + //then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰").optional() + ), + requestHeaders( + headerWithName("Authorization") + .description("access token").optional() + ), + queryParameters( + parameterWithName("year").description("찾고자 하는 잡페어 인터뷰 연도").optional(), + parameterWithName("category").description("찾고자 하는 잡페어 인터뷰 카테고리: SENIOR, INTERN").optional(), + parameterWithName("title").description("찾고자 하는 잡페어 인터뷰의 제목").optional(), + parameterWithName("page").description("페이지 번호 [default: 0]").optional(), + parameterWithName("size").description("페이지 크기 [default: 10]").optional() + ), + responseFields( + //page + fieldWithPath("pageable").type(JsonFieldType.OBJECT).description("페이지 정보"), + fieldWithPath("pageable.pageNumber").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("pageable.pageSize").type(JsonFieldType.NUMBER).description("페이지 당 요소 수"), + fieldWithPath("pageable.sort.empty").type(JsonFieldType.BOOLEAN).description("정렬 정보가 비어있는지 여부"), + fieldWithPath("pageable.sort.sorted").type(JsonFieldType.BOOLEAN).description("정렬된 상태인지 여부"), + fieldWithPath("pageable.sort.unsorted").type(JsonFieldType.BOOLEAN).description("정렬되지 않은 상태인지 여부"), + fieldWithPath("pageable.offset").type(JsonFieldType.NUMBER).description("오프셋"), + fieldWithPath("pageable.paged").type(JsonFieldType.BOOLEAN).description("페이징된 여부"), + fieldWithPath("pageable.unpaged").type(JsonFieldType.BOOLEAN).description("페이징되지 않은 여부"), + fieldWithPath("totalElements").type(JsonFieldType.NUMBER).description("전체 요소 수"), + fieldWithPath("totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), + fieldWithPath("size").type(JsonFieldType.NUMBER).description("페이지 당 요소 수"), + fieldWithPath("number").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("numberOfElements").type(JsonFieldType.NUMBER).description("현재 페이지 요소 수"), + fieldWithPath("first").type(JsonFieldType.BOOLEAN).description("첫 페이지 여부"), + fieldWithPath("last").type(JsonFieldType.BOOLEAN).description("마지막 페이지 여부"), + fieldWithPath("sort.empty").type(JsonFieldType.BOOLEAN).description("정렬 정보가 비어있는지 여부"), + fieldWithPath("sort.unsorted").type(JsonFieldType.BOOLEAN).description("정렬되지 않은 상태인지 여부"), + fieldWithPath("sort.sorted").type(JsonFieldType.BOOLEAN).description("정렬된 상태인지 여부"), + fieldWithPath("empty").type(JsonFieldType.BOOLEAN).description("비어있는 페이지 여부"), + //content + fieldWithPath("content[].id").type(JsonFieldType.NUMBER).description("잡페어 인터뷰 ID"), + fieldWithPath("content[].title").type(JsonFieldType.STRING).description("잡페어 인터뷰 제목"), + fieldWithPath("content[].youtubeId").type(JsonFieldType.STRING).description("유튜브 영상의 고유 ID"), + fieldWithPath("content[].year").type(JsonFieldType.NUMBER).description("잡페어 인터뷰 연도"), + fieldWithPath("content[].talkerBelonging").type(JsonFieldType.STRING).description("잡페어 인터뷰 대담자의 소속"), + fieldWithPath("content[].talkerName").type(JsonFieldType.STRING).description("잡페어 인터뷰 대담자의 성명"), + fieldWithPath("content[].favorite").type(JsonFieldType.BOOLEAN).description("관심에 추가한 잡페어 인터뷰 여부"), + fieldWithPath("content[].category").type(JsonFieldType.STRING).description("잡페어 인터뷰 카테고리: SENIOR, INTERN"), + fieldWithPath("content[].createdAt").type(JsonFieldType.STRING).description("잡페어 인터뷰 생성일"), + fieldWithPath("content[].updatedAt").type(JsonFieldType.STRING).description("잡페어 인터뷰 수정일") + ) + )); + } + + @Test + @DisplayName("잡페어 인터뷰 1개를 조회할 수 있다.") + void getJobInterview() throws Exception { + //given + JobInterviewUserResponse response = new JobInterviewUserResponse(1L, "잡페어 인터뷰의 제목", "유튜브 고유 ID", 2024,"대담자의 소속", "대담자의 성명", false, JobInterviewCategory.INTERN, LocalDateTime.now(), LocalDateTime.now()); + + when(jobInterviewService.getJobInterview(any(), any())).thenReturn(response); + + //when + ResultActions result = mockMvc.perform( + RestDocumentationRequestBuilders.get("/jobInterviews/{jobInterviewId}", 1L) + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, OPTIONAL_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + //then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰").optional() + ), + requestHeaders( + headerWithName("Authorization") + .description("access token").optional() + ), + pathParameters( + parameterWithName("jobInterviewId").description("조회할 잡페어 인터뷰의 ID") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("잡페어 인터뷰 ID"), + fieldWithPath("title").type(JsonFieldType.STRING).description("잡페어 인터뷰 제목"), + fieldWithPath("youtubeId").type(JsonFieldType.STRING).description("유튜브 영상의 고유 ID"), + fieldWithPath("year").type(JsonFieldType.NUMBER).description("잡페어 인터뷰 연도"), + fieldWithPath("talkerBelonging").type(JsonFieldType.STRING).description("잡페어 인터뷰 대담자의 소속"), + fieldWithPath("talkerName").type(JsonFieldType.STRING).description("잡페어 인터뷰 대담자의 성명"), + fieldWithPath("favorite").type(JsonFieldType.BOOLEAN).description("관심에 추가한 잡페어 인터뷰 여부"), + fieldWithPath("category").type(JsonFieldType.STRING).description("잡페어 인터뷰 카테고리: SENIOR, INTERN"), + fieldWithPath("createdAt").type(JsonFieldType.STRING).description("잡페어 인터뷰 생성일"), + fieldWithPath("updatedAt").type(JsonFieldType.STRING).description("잡페어 인터뷰 수정일") + ) + )); + + } + + @Test + @DisplayName("잡페어 인터뷰를 수정할 수 있다.") + void updateJobInterview() throws Exception { + //given + JobInterviewRequest request = new JobInterviewRequest("수정된 제목", "수정된 유튜브 ID", 2024, "수정된 대담자 소속", "수정된 대담자 성명", JobInterviewCategory.INTERN); + JobInterviewResponse response = new JobInterviewResponse(1L, "수정된 제목", "수정된 유튜브 ID", 2024,"수정된 대담자 소속", "수정된 대담자 성명", JobInterviewCategory.INTERN, LocalDateTime.of(2021,1,1,12,0), LocalDateTime.now()); + + when(jobInterviewService.updateJobInterview(any(), any(JobInterviewRequest.class))).thenReturn(response); + + //when + ResultActions result = mockMvc.perform( + RestDocumentationRequestBuilders.put("/jobInterviews/{jobInterviewId}", 1L) + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + .content(objectMapper.writeValueAsString(request)) + ); + + //then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + pathParameters( + parameterWithName("jobInterviewId").description("수정할 잡페어 인터뷰의 ID") + ), + requestFields( + fieldWithPath("title").type(JsonFieldType.STRING).description("잡페어 인터뷰 제목"), + fieldWithPath("youtubeId").type(JsonFieldType.STRING).description("유튜브 영상의 고유 ID"), + fieldWithPath("year").type(JsonFieldType.NUMBER).description("잡페어 인터뷰 연도"), + fieldWithPath("talkerBelonging").type(JsonFieldType.STRING).description("잡페어 인터뷰 대담자의 소속"), + fieldWithPath("talkerName").type(JsonFieldType.STRING).description("잡페어 인터뷰 대담자의 성명"), + fieldWithPath("category").type(JsonFieldType.STRING).description("잡페어 인터뷰 카테고리: SENIOR, INTERN") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("잡페어 인터뷰 ID"), + fieldWithPath("title").type(JsonFieldType.STRING).description("잡페어 인터뷰 제목"), + fieldWithPath("youtubeId").type(JsonFieldType.STRING).description("유튜브 영상의 고유 ID"), + fieldWithPath("year").type(JsonFieldType.NUMBER).description("잡페어 인터뷰 연도"), + fieldWithPath("talkerBelonging").type(JsonFieldType.STRING).description("잡페어 인터뷰 대담자의 소속"), + fieldWithPath("talkerName").type(JsonFieldType.STRING).description("잡페어 인터뷰 대담자의 성명"), + fieldWithPath("category").type(JsonFieldType.STRING).description("잡페어 인터뷰 카테고리: SENIOR, INTERN"), + fieldWithPath("createdAt").type(JsonFieldType.STRING).description("잡페어 인터뷰 생성일"), + fieldWithPath("updatedAt").type(JsonFieldType.STRING).description("잡페어 인터뷰 수정일") + ) + )); + } + + @Test + @DisplayName("잡페어 인터뷰를 삭제할 수 있다.") + void deleteJobInterview() throws Exception { + //given + doNothing().when(jobInterviewService).deleteJobInterviewById(any()); + //when + ResultActions result = mockMvc.perform( + RestDocumentationRequestBuilders.delete("/jobInterviews/{jobInterviewId}", 1L) + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + //then + result.andExpect(status().isNoContent()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + pathParameters( + parameterWithName("jobInterviewId").description("삭제할 잡페어 인터뷰의 ID") + ) + )); + } + + @Test + @DisplayName("잡페어 인터뷰를 관심 등록할 수 있다.") + void createJobInterviewFavorite() throws Exception { + //given + Long id = 1L; + doNothing().when(favoriteVideoService).createJobInterviewFavorite(anyLong(), any()); + //when + ResultActions result = mockMvc.perform( + RestDocumentationRequestBuilders.post("/jobInterviews/{jobInterviewId}/favorite", id) + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, USER_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + //then + result.andExpect(status().isCreated()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + pathParameters( + parameterWithName("jobInterviewId").description("관심 목록에 추가할 잡페어 인터뷰의 ID") + ) + )); + } + + @Test + @DisplayName("잡페어 인터뷰를 관심 등록 취소할 수 있다.") + void deleteJobInterviewFavorite() throws Exception { + //given + Long id = 1L; + doNothing().when(favoriteVideoService).deleteJobInterviewFavorite(anyLong(), any()); + //when + ResultActions result = mockMvc.perform( + RestDocumentationRequestBuilders.delete("/jobInterviews/{jobInterviewId}/favorite", id) + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, USER_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + //then + result.andExpect(status().isNoContent()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + pathParameters( + parameterWithName("jobInterviewId").description("관심 목록에서 삭제할 잡페어 인터뷰의 ID") + ) + )); + + } + + + + +} diff --git a/src/test/java/com/scg/stop/video/controller/QuizControllerTest.java b/src/test/java/com/scg/stop/video/controller/QuizControllerTest.java new file mode 100644 index 00000000..e9c592f7 --- /dev/null +++ b/src/test/java/com/scg/stop/video/controller/QuizControllerTest.java @@ -0,0 +1,155 @@ +package com.scg.stop.video.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.scg.stop.configuration.AbstractControllerTest; +import com.scg.stop.global.excel.Excel; +import com.scg.stop.video.dto.response.UserQuizResultResponse; +import com.scg.stop.video.service.QuizService; +import jakarta.servlet.http.Cookie; +import org.apache.poi.xssf.streaming.SXSSFWorkbook; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; +import static org.springframework.restdocs.cookies.CookieDocumentation.requestCookies; +import static org.springframework.restdocs.headers.HeaderDocumentation.*; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(QuizController.class) +@MockBean(JpaMetamodelMappingContext.class) +@AutoConfigureRestDocs +public class QuizControllerTest extends AbstractControllerTest { + @MockBean + private QuizService quizService; + + @Autowired + private ObjectMapper objectMapper; + + private static final String ACCESS_TOKEN = "admin_access_token"; + private static final String REFRESH_TOKEN = "refresh_token"; + + @Test + @DisplayName("관리자는 올해의 퀴즈 제출 기록을 가져올 수 있다.") + void getQuizResults() throws Exception { + //given + UserQuizResultResponse response1 = new UserQuizResultResponse(1L,"name", "010-1111-1111","scg@scg.skku.ac.kr", 3L); + UserQuizResultResponse response2 = new UserQuizResultResponse(2L, "name2", "010-0000-1234", "iam@2tle.io",1L); + Page pages = new PageImpl<>(List.of(response1, response2), PageRequest.of(0,10), 2); + + when(quizService.getQuizResults(any(), any())).thenReturn(pages); + //when + + ResultActions result = mockMvc.perform( + get("/quizzes/result") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + //then + + result.andExpect(status().isOk()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + queryParameters( + parameterWithName("year").description("찾고자 하는 퀴즈 푼 문제의 연도").optional(), + parameterWithName("page").description("페이지 번호 [default: 0]").optional(), + parameterWithName("size").description("페이지 크기 [default: 10]").optional() + ), + responseFields( + //page + fieldWithPath("pageable").type(JsonFieldType.OBJECT).description("페이지 정보"), + fieldWithPath("pageable.pageNumber").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("pageable.pageSize").type(JsonFieldType.NUMBER).description("페이지 당 요소 수"), + fieldWithPath("pageable.sort.empty").type(JsonFieldType.BOOLEAN).description("정렬 정보가 비어있는지 여부"), + fieldWithPath("pageable.sort.sorted").type(JsonFieldType.BOOLEAN).description("정렬된 상태인지 여부"), + fieldWithPath("pageable.sort.unsorted").type(JsonFieldType.BOOLEAN).description("정렬되지 않은 상태인지 여부"), + fieldWithPath("pageable.offset").type(JsonFieldType.NUMBER).description("오프셋"), + fieldWithPath("pageable.paged").type(JsonFieldType.BOOLEAN).description("페이징된 여부"), + fieldWithPath("pageable.unpaged").type(JsonFieldType.BOOLEAN).description("페이징되지 않은 여부"), + fieldWithPath("totalElements").type(JsonFieldType.NUMBER).description("전체 요소 수"), + fieldWithPath("totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), + fieldWithPath("size").type(JsonFieldType.NUMBER).description("페이지 당 요소 수"), + fieldWithPath("number").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("numberOfElements").type(JsonFieldType.NUMBER).description("현재 페이지 요소 수"), + fieldWithPath("first").type(JsonFieldType.BOOLEAN).description("첫 페이지 여부"), + fieldWithPath("last").type(JsonFieldType.BOOLEAN).description("마지막 페이지 여부"), + fieldWithPath("sort.empty").type(JsonFieldType.BOOLEAN).description("정렬 정보가 비어있는지 여부"), + fieldWithPath("sort.unsorted").type(JsonFieldType.BOOLEAN).description("정렬되지 않은 상태인지 여부"), + fieldWithPath("sort.sorted").type(JsonFieldType.BOOLEAN).description("정렬된 상태인지 여부"), + fieldWithPath("empty").type(JsonFieldType.BOOLEAN).description("비어있는 페이지 여부"), + //content + fieldWithPath("content[].userId").type(JsonFieldType.NUMBER).description("유저의 아이디"), + fieldWithPath("content[].name").type(JsonFieldType.STRING).description("유저의 이름"), + fieldWithPath("content[].phone").type(JsonFieldType.STRING).description("유저의 연락처"), + fieldWithPath("content[].email").type(JsonFieldType.STRING).description("유저의 이메일"), + fieldWithPath("content[].successCount").type(JsonFieldType.NUMBER).description("유저가 퀴즈를 성공한 횟수의 합") + ) + )); + } + + @Test + @DisplayName("관리자는 퀴즈 데이터를 엑셀로 다운로드 받을 수 있다.") + void getQuizResultsToExcel() throws Exception { + //given + //String contentType = "ms-vnd/excel"; + SXSSFWorkbook workbook = new SXSSFWorkbook(); + Excel excel = new Excel("excel.xlsx", workbook); + when(quizService.getQuizResultToExcel(any())).thenReturn(excel); + + //when + ResultActions result = mockMvc.perform( + get("/quizzes/result/excel") + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .header(HttpHeaders.AUTHORIZATION, ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + result.andExpect(status().isOk()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + queryParameters( + parameterWithName("year").description("찾고자 하는 퀴즈 푼 문제의 연도, 기본값 올해").optional() + ) + )); + + + } +} diff --git a/src/test/java/com/scg/stop/video/controller/TalkControllerTest.java b/src/test/java/com/scg/stop/video/controller/TalkControllerTest.java new file mode 100644 index 00000000..67b7ea12 --- /dev/null +++ b/src/test/java/com/scg/stop/video/controller/TalkControllerTest.java @@ -0,0 +1,560 @@ +package com.scg.stop.video.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.scg.stop.configuration.AbstractControllerTest; +import com.scg.stop.video.controller.TalkController; +import com.scg.stop.video.domain.QuizInfo; +import com.scg.stop.video.dto.request.QuizInfoRequest; +import com.scg.stop.video.dto.request.QuizRequest; +import com.scg.stop.video.dto.request.QuizSubmitRequest; +import com.scg.stop.video.dto.request.TalkRequest; +import com.scg.stop.video.dto.response.QuizResponse; +import com.scg.stop.video.dto.response.QuizSubmitResponse; +import com.scg.stop.video.dto.response.TalkResponse; +import com.scg.stop.video.dto.response.TalkUserResponse; +import com.scg.stop.video.service.FavoriteVideoService; +import com.scg.stop.video.service.QuizService; +import com.scg.stop.video.service.TalkService; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.web.servlet.ResultActions; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.doNothing; +import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; +import static org.springframework.restdocs.cookies.CookieDocumentation.requestCookies; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(TalkController.class) +@MockBean(JpaMetamodelMappingContext.class) +@AutoConfigureRestDocs +public class TalkControllerTest extends AbstractControllerTest { + + private static final String ACCESS_TOKEN = "admin_access_token"; + private static final String USER_ACCESS_TOKEN = "access_token"; + private static final String OPTIONAL_ACCESS_TOKEN = "optional_access_token"; + private static final String REFRESH_TOKEN = "refresh_token"; + + @MockBean + private TalkService talkService; + + @MockBean + private QuizService quizService; + + @MockBean + private FavoriteVideoService favoriteVideoService; + + @Autowired + private ObjectMapper objectMapper; + + @Test + @DisplayName("대담영상을 생성한다.") + void createTalk() throws Exception { + + //given + List quizData = new ArrayList<>(); + quizData.add(new QuizInfoRequest("질문1", 0, List.of("선지1","선지2"))); + quizData.add(new QuizInfoRequest("질문2", 0, List.of("선지1","선지2"))); + QuizRequest quizRequest = new QuizRequest(quizData); + QuizResponse quizResponse = new QuizResponse( + List.of( + new QuizInfo("질문1", 0, List.of("선지1","선지2")), + new QuizInfo("질문2", 0, List.of("선지1","선지2")) + ) + ); + TalkRequest request= new TalkRequest("제목","유튜브 고유ID", 2024, "대담자 소속", "대담자 성명",quizRequest); + TalkResponse response = new TalkResponse(1L, "제목", "유튜브 고유ID", 2024, "대담자 소속","대담자 성명" ,quizResponse,LocalDateTime.now(), LocalDateTime.now()); + + when(talkService.createTalk(any())).thenReturn(response); + + //when + ResultActions result = mockMvc.perform( + post("/talks") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + .content(objectMapper.writeValueAsString(request)) + ); + + //then + result.andExpect(status().isCreated()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + requestFields( + fieldWithPath("title").type(JsonFieldType.STRING).description("대담 영상 제목"), + fieldWithPath("youtubeId").type(JsonFieldType.STRING).description("유튜브 영상의 고유 ID"), + fieldWithPath("year").type(JsonFieldType.NUMBER).description("대담 영상 연도"), + fieldWithPath("talkerBelonging").type(JsonFieldType.STRING).description("대담자의 소속된 직장/단체"), + fieldWithPath("talkerName").type(JsonFieldType.STRING).description("대담자의 성명"), + fieldWithPath("quiz").type(JsonFieldType.ARRAY).description("퀴즈 데이터, 없는경우 null").optional(), + fieldWithPath("quiz[].question").type(JsonFieldType.STRING).description("퀴즈 1개의 질문").optional(), + fieldWithPath("quiz[].answer").type(JsonFieldType.NUMBER).description("퀴즈 1개의 정답선지 인덱스").optional(), + fieldWithPath("quiz[].options").type(JsonFieldType.ARRAY).description("퀴즈 1개의 정답선지 리스트").optional() + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("대담 영상 ID"), + fieldWithPath("title").type(JsonFieldType.STRING).description("대담 영상 제목"), + fieldWithPath("youtubeId").type(JsonFieldType.STRING).description("유튜브 영상의 고유 ID"), + fieldWithPath("year").type(JsonFieldType.NUMBER).description("대담 영상 연도"), + fieldWithPath("talkerBelonging").type(JsonFieldType.STRING).description("대담자의 소속된 직장/단체"), + fieldWithPath("talkerName").type(JsonFieldType.STRING).description("대담자의 성명"), + fieldWithPath("quiz").type(JsonFieldType.ARRAY).description("퀴즈 데이터, 없는경우 null").optional(), + fieldWithPath("quiz[].question").type(JsonFieldType.STRING).description("퀴즈 1개의 질문").optional(), + fieldWithPath("quiz[].answer").type(JsonFieldType.NUMBER).description("퀴즈 1개의 정답선지 인덱스").optional(), + fieldWithPath("quiz[].options").type(JsonFieldType.ARRAY).description("퀴즈 1개의 정답선지 리스트").optional(), + fieldWithPath("createdAt").type(JsonFieldType.STRING).description("대담 영상 생성일"), + fieldWithPath("updatedAt").type(JsonFieldType.STRING).description("대담 영상 수정일") + ) + )); + } + + @Test + @DisplayName("대담 영상 리스트를 조회할 수 있다.") + void getTalkList() throws Exception { + //given + QuizResponse quizResponse = new QuizResponse( + List.of( + new QuizInfo("질문1", 0, List.of("선지1","선지2")), + new QuizInfo("질문2", 0, List.of("선지1","선지2")) + ) + ); + TalkUserResponse response = new TalkUserResponse(1L, "제목", "유튜브 고유ID", 2024, "대담자 소속","대담자 성명" ,true,quizResponse,LocalDateTime.now(), LocalDateTime.now()); + Page page = new PageImpl<>(List.of(response), PageRequest.of(0,10),1); + + when(talkService.getTalks(any(), any(), any(), any())).thenReturn(page); + + //when + ResultActions result = mockMvc.perform( + get("/talks") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, OPTIONAL_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + //then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰").optional() + ), + requestHeaders( + headerWithName("Authorization") + .description("access token").optional() + ), + queryParameters( + parameterWithName("year").description("찾고자 하는 대담 영상의 연도").optional(), + parameterWithName("title").description("찾고자 하는 대담 영상의 제목 일부").optional(), + parameterWithName("page").description("페이지 번호 [default: 0]").optional(), + parameterWithName("size").description("페이지 크기 [default: 10]").optional() + ), + responseFields( + //page + fieldWithPath("pageable").type(JsonFieldType.OBJECT).description("페이지 정보"), + fieldWithPath("pageable.pageNumber").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("pageable.pageSize").type(JsonFieldType.NUMBER).description("페이지 당 요소 수"), + fieldWithPath("pageable.sort.empty").type(JsonFieldType.BOOLEAN).description("정렬 정보가 비어있는지 여부"), + fieldWithPath("pageable.sort.sorted").type(JsonFieldType.BOOLEAN).description("정렬된 상태인지 여부"), + fieldWithPath("pageable.sort.unsorted").type(JsonFieldType.BOOLEAN).description("정렬되지 않은 상태인지 여부"), + fieldWithPath("pageable.offset").type(JsonFieldType.NUMBER).description("오프셋"), + fieldWithPath("pageable.paged").type(JsonFieldType.BOOLEAN).description("페이징된 여부"), + fieldWithPath("pageable.unpaged").type(JsonFieldType.BOOLEAN).description("페이징되지 않은 여부"), + fieldWithPath("totalElements").type(JsonFieldType.NUMBER).description("전체 요소 수"), + fieldWithPath("totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), + fieldWithPath("size").type(JsonFieldType.NUMBER).description("페이지 당 요소 수"), + fieldWithPath("number").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("numberOfElements").type(JsonFieldType.NUMBER).description("현재 페이지 요소 수"), + fieldWithPath("first").type(JsonFieldType.BOOLEAN).description("첫 페이지 여부"), + fieldWithPath("last").type(JsonFieldType.BOOLEAN).description("마지막 페이지 여부"), + fieldWithPath("sort.empty").type(JsonFieldType.BOOLEAN).description("정렬 정보가 비어있는지 여부"), + fieldWithPath("sort.unsorted").type(JsonFieldType.BOOLEAN).description("정렬되지 않은 상태인지 여부"), + fieldWithPath("sort.sorted").type(JsonFieldType.BOOLEAN).description("정렬된 상태인지 여부"), + fieldWithPath("empty").type(JsonFieldType.BOOLEAN).description("비어있는 페이지 여부"), + //content + fieldWithPath("content[].id").type(JsonFieldType.NUMBER).description("대담 영상 ID"), + fieldWithPath("content[].title").type(JsonFieldType.STRING).description("대담 영상 제목"), + fieldWithPath("content[].youtubeId").type(JsonFieldType.STRING).description("유튜브 영상의 고유 ID"), + fieldWithPath("content[].year").type(JsonFieldType.NUMBER).description("대담 영상 연도"), + fieldWithPath("content[].talkerBelonging").type(JsonFieldType.STRING).description("대담자의 소속된 직장/단체"), + fieldWithPath("content[].talkerName").type(JsonFieldType.STRING).description("대담자의 성명"), + fieldWithPath("content[].favorite").type(JsonFieldType.BOOLEAN).description("관심한 대담영상의 여부"), + fieldWithPath("content[].quiz").type(JsonFieldType.ARRAY).description("퀴즈 데이터, 없는경우 null").optional(), + fieldWithPath("content[].quiz[].question").type(JsonFieldType.STRING).description("퀴즈 1개의 질문").optional(), + fieldWithPath("content[].quiz[].answer").type(JsonFieldType.NUMBER).description("퀴즈 1개의 정답선지 인덱스").optional(), + fieldWithPath("content[].quiz[].options").type(JsonFieldType.ARRAY).description("퀴즈 1개의 정답선지 리스트").optional(), + fieldWithPath("content[].createdAt").type(JsonFieldType.STRING).description("대담 영상 생성일"), + fieldWithPath("content[].updatedAt").type(JsonFieldType.STRING).description("대담 영상 수정일") + ) + )); + } + + @Test + @DisplayName("대담 영상을 1개 조회할 수 있다.") + void getTalk() throws Exception { + //given + Long id = 1L; + QuizResponse quizResponse = new QuizResponse( + List.of( + new QuizInfo("질문1", 0, List.of("선지1","선지2")), + new QuizInfo("질문2", 0, List.of("선지1","선지2")) + ) + ); + TalkUserResponse response = new TalkUserResponse(id, "제목", "유튜브 고유ID", 2024, "대담자 소속","대담자 성명" ,true,quizResponse,LocalDateTime.now(), LocalDateTime.now()); + when(talkService.getTalkById(anyLong(), any())).thenReturn(response); + + //when + ResultActions result = mockMvc.perform( + get("/talks/{talkId}", id) + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, OPTIONAL_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + //then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰").optional() + ), + requestHeaders( + headerWithName("Authorization") + .description("access token").optional() + ), + pathParameters( + parameterWithName("talkId").description("조회할 대담 영상의 ID") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("대담 영상 ID"), + fieldWithPath("title").type(JsonFieldType.STRING).description("대담 영상 제목"), + fieldWithPath("youtubeId").type(JsonFieldType.STRING).description("유튜브 영상의 고유 ID"), + fieldWithPath("year").type(JsonFieldType.NUMBER).description("대담 영상 연도"), + fieldWithPath("talkerBelonging").type(JsonFieldType.STRING).description("대담자의 소속된 직장/단체"), + fieldWithPath("talkerName").type(JsonFieldType.STRING).description("대담자의 성명"), + fieldWithPath("favorite").type(JsonFieldType.BOOLEAN).description("관심한 대담영상의 여부"), + fieldWithPath("quiz").type(JsonFieldType.ARRAY).description("퀴즈 데이터, 없는경우 null").optional(), + fieldWithPath("quiz[].question").type(JsonFieldType.STRING).description("퀴즈 1개의 질문").optional(), + fieldWithPath("quiz[].answer").type(JsonFieldType.NUMBER).description("퀴즈 1개의 정답선지 인덱스").optional(), + fieldWithPath("quiz[].options").type(JsonFieldType.ARRAY).description("퀴즈 1개의 정답선지 리스트").optional(), + fieldWithPath("createdAt").type(JsonFieldType.STRING).description("대담 영상 생성일"), + fieldWithPath("updatedAt").type(JsonFieldType.STRING).description("대담 영상 수정일") + ) + )); + + } + + @Test + @DisplayName("대담 영상을 수정할 수 있다.") + void updateTalk() throws Exception { + //given + Long id = 1L; + List quizData = new ArrayList<>(); + quizData.add(new QuizInfoRequest("수정한 질문1", 0, List.of("수정한 선지1","수정한 선지2"))); + quizData.add(new QuizInfoRequest("수정한 질문2", 0, List.of("수정한 선지1","수정한 선지2"))); + QuizRequest quizRequest = new QuizRequest(quizData); + QuizResponse quizResponse = new QuizResponse( + List.of( + new QuizInfo("수정한 질문1", 0, List.of("수정한 선지1","수정한 선지2")), + new QuizInfo("수정한 질문2", 0, List.of("수정한 선지1","수정한 선지2")) + ) + ); + TalkRequest request= new TalkRequest("수정한 제목","수정한 유튜브 고유ID", 2024, "수정한 대담자 소속", "수정한 대담자 성명",quizRequest); + TalkResponse response = new TalkResponse(id, "수정한 제목", "수정한 유튜브 고유ID", 2024, "수정한 대담자 소속","수정한 대담자 성명" ,quizResponse,LocalDateTime.now(), LocalDateTime.now()); + when(talkService.updateTalk(anyLong(), any(TalkRequest.class))).thenReturn(response); + + //when + ResultActions result = mockMvc.perform( + put("/talks/{talkId}", id) + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + .content(objectMapper.writeValueAsString(request)) + ); + + //then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + pathParameters( + parameterWithName("talkId").description("수정할 대담 영상의 ID") + ), + requestFields( + fieldWithPath("title").type(JsonFieldType.STRING).description("대담 영상 제목"), + fieldWithPath("youtubeId").type(JsonFieldType.STRING).description("유튜브 영상의 고유 ID"), + fieldWithPath("year").type(JsonFieldType.NUMBER).description("대담 영상 연도"), + fieldWithPath("talkerBelonging").type(JsonFieldType.STRING).description("대담자의 소속된 직장/단체"), + fieldWithPath("talkerName").type(JsonFieldType.STRING).description("대담자의 성명"), + fieldWithPath("quiz").type(JsonFieldType.ARRAY).description("퀴즈 데이터, 없는경우 null").optional(), + fieldWithPath("quiz[].question").type(JsonFieldType.STRING).description("퀴즈 1개의 질문").optional(), + fieldWithPath("quiz[].answer").type(JsonFieldType.NUMBER).description("퀴즈 1개의 정답선지 인덱스").optional(), + fieldWithPath("quiz[].options").type(JsonFieldType.ARRAY).description("퀴즈 1개의 정답선지 리스트").optional() + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("대담 영상 ID"), + fieldWithPath("title").type(JsonFieldType.STRING).description("대담 영상 제목"), + fieldWithPath("youtubeId").type(JsonFieldType.STRING).description("유튜브 영상의 고유 ID"), + fieldWithPath("year").type(JsonFieldType.NUMBER).description("대담 영상 연도"), + fieldWithPath("talkerBelonging").type(JsonFieldType.STRING).description("대담자의 소속된 직장/단체"), + fieldWithPath("talkerName").type(JsonFieldType.STRING).description("대담자의 성명"), + fieldWithPath("quiz").type(JsonFieldType.ARRAY).description("퀴즈 데이터, 없는경우 null").optional(), + fieldWithPath("quiz[].question").type(JsonFieldType.STRING).description("퀴즈 1개의 질문").optional(), + fieldWithPath("quiz[].answer").type(JsonFieldType.NUMBER).description("퀴즈 1개의 정답선지 인덱스").optional(), + fieldWithPath("quiz[].options").type(JsonFieldType.ARRAY).description("퀴즈 1개의 정답선지 리스트").optional(), + fieldWithPath("createdAt").type(JsonFieldType.STRING).description("대담 영상 생성일"), + fieldWithPath("updatedAt").type(JsonFieldType.STRING).description("대담 영상 수정일") + ) + )); + } + + @Test + @DisplayName("대담 영상을 삭제할 수 있다.") + void deleteTalk() throws Exception { + //given + doNothing().when(talkService).deleteTalk(anyLong()); + + //when + ResultActions result = mockMvc.perform( + delete("/talks/{talkId}", 1L) + .header(HttpHeaders.AUTHORIZATION, ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + //then + result.andExpect(status().isNoContent()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + pathParameters( + parameterWithName("talkId").description("삭제할 대담 영상의 ID") + ) + )); + } + + @Test + @DisplayName("대담 영상에 등록된 퀴즈 1개를 가져올 수 있다.") + void getQuiz() throws Exception { + //given + Long id = 1L; + QuizResponse quizResponse = new QuizResponse( + List.of( + new QuizInfo("질문1", 0, List.of("선지1","선지2")), + new QuizInfo("질문2", 0, List.of("선지1","선지2")) + ) + ); + when(quizService.getQuiz(anyLong())).thenReturn(quizResponse); + + //when + ResultActions result = mockMvc.perform( + get("/talks/{talkId}/quiz", id) + .contentType(MediaType.APPLICATION_JSON) + ); + + //then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + pathParameters( + parameterWithName("talkId").description("퀴즈를 가져올 대담 영상의 ID") + ), + responseFields( + fieldWithPath("quiz").type(JsonFieldType.ARRAY).description("퀴즈 데이터, 없는경우 null").optional(), + fieldWithPath("quiz[].question").type(JsonFieldType.STRING).description("퀴즈 1개의 질문").optional(), + fieldWithPath("quiz[].answer").type(JsonFieldType.NUMBER).description("퀴즈 1개의 정답선지 인덱스").optional(), + fieldWithPath("quiz[].options").type(JsonFieldType.ARRAY).description("퀴즈 1개의 정답선지 리스트").optional() + ) + )); + + + } + + //퀴즈 제출 + @Test + @DisplayName("퀴즈를 제출할 수 있다.") + void submitQuiz() throws Exception { + //given + Map quizAnswer = new HashMap<>(); + quizAnswer.put("0", 0); + quizAnswer.put("1", 1); + QuizSubmitRequest request = new QuizSubmitRequest(quizAnswer); + QuizSubmitResponse response = new QuizSubmitResponse(true, 1); + + //when + when(quizService.submitQuiz(anyLong(), any(QuizSubmitRequest.class), any())).thenReturn(response); + ResultActions result = mockMvc.perform( + post("/talks/{talkId}/quiz", 1L) + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, USER_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + .content(objectMapper.writeValueAsString(request)) + ); + + //then + result.andExpect(status().isOk()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + pathParameters( + parameterWithName("talkId").description("퀴즈를 제출할 대담 영상의 ID") + ), + requestFields( + fieldWithPath("result").type(JsonFieldType.OBJECT).description("퀴즈를 푼 결과"), + fieldWithPath("result.*").type(JsonFieldType.NUMBER).description("퀴즈 각 문제별 정답 인덱스, key는 문제 번호") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("퀴즈 성공 여부"), + fieldWithPath("tryCount").type(JsonFieldType.NUMBER).description("퀴즈 시도 횟수") + ) + )); + } + + @Test + @DisplayName("대담 영상을 관심 목록에 추가할 수 있다.") + void createTalkFavorite() throws Exception { + //given + Long id = 1L; + doNothing().when(favoriteVideoService).createTalkFavorite(anyLong(), any()); + //when + ResultActions result = mockMvc.perform( + post("/talks/{talkId}/favorite", id) + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, USER_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + //then + result.andExpect(status().isCreated()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + pathParameters( + parameterWithName("talkId").description("관심 목록에 추가할 대담 영상의 ID") + ) + )); + + } + + @Test + @DisplayName("대담 영상을 관심 목록서 삭제할 수 있다.") + void deleteTalkFavorite() throws Exception { + //given + Long id = 1L; + doNothing().when(favoriteVideoService).deleteTalkFavorite(anyLong(), any()); + //when + ResultActions result = mockMvc.perform( + delete("/talks/{talkId}/favorite", id) + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, USER_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + //then + result.andExpect(status().isNoContent()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + pathParameters( + parameterWithName("talkId").description("관심 목록에서 삭제할 대담 영상의 ID") + ) + )); + + } + + @Test + @DisplayName("유저 자신의 퀴즈 제출 기록을 확인할 수 있다.") + void getUserQuiz() throws Exception { + Long id = 1L; + QuizSubmitResponse response = new QuizSubmitResponse(false, 2); + when(quizService.getUserQuiz(anyLong(), any())).thenReturn(response); + + ResultActions result = mockMvc.perform( + get("/talks/{talkId}/quiz/submit", id) + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, USER_ACCESS_TOKEN) + .cookie(new Cookie("refresh-token", REFRESH_TOKEN)) + ); + + result.andExpect(status().isOk()) + .andDo(restDocs.document( + requestCookies( + cookieWithName("refresh-token") + .description("갱신 토큰") + ), + requestHeaders( + headerWithName("Authorization") + .description("access token") + ), + pathParameters( + parameterWithName("talkId").description("퀴즈가 연결된 대담 영상의 ID") + ), + responseFields( + fieldWithPath("tryCount").type(JsonFieldType.NUMBER).description("시도한 횟수"), + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("퀴즈 성공 여부") + ) + )); + + } + + + + +} diff --git a/src/test/resources/org/springframework/restdocs/templates/exception-response-fields.snippet b/src/test/resources/org/springframework/restdocs/templates/exception-response-fields.snippet new file mode 100644 index 00000000..9ca402a3 --- /dev/null +++ b/src/test/resources/org/springframework/restdocs/templates/exception-response-fields.snippet @@ -0,0 +1,8 @@ +|=== +|Code|Message + +{{#fields}} +|{{#tableCellContent}}{{code}}{{/tableCellContent}} +|{{#tableCellContent}}{{message}}{{/tableCellContent}} +{{/fields}} +|=== diff --git a/src/test/resources/org/springframework/restdocs/templates/query-parameters.snippet b/src/test/resources/org/springframework/restdocs/templates/query-parameters.snippet new file mode 100644 index 00000000..8be79495 --- /dev/null +++ b/src/test/resources/org/springframework/restdocs/templates/query-parameters.snippet @@ -0,0 +1,10 @@ +|=== +|Parameter|Required|Description + +{{#parameters}} +|{{#tableCellContent}}`+{{name}}+`{{/tableCellContent}} +|{{#tableCellContent}}{{^optional}}true{{/optional}}{{#optional}}false{{/optional}}{{/tableCellContent}} +|{{#tableCellContent}}{{description}}{{/tableCellContent}} + +{{/parameters}} +|=== \ No newline at end of file diff --git a/src/test/resources/org/springframework/restdocs/templates/request-fields.snippet b/src/test/resources/org/springframework/restdocs/templates/request-fields.snippet new file mode 100644 index 00000000..154e137c --- /dev/null +++ b/src/test/resources/org/springframework/restdocs/templates/request-fields.snippet @@ -0,0 +1,10 @@ +|=== +|Name|Type|Required|Description + +{{#fields}} +|{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}} +|{{#tableCellContent}}`+{{type}}+`{{/tableCellContent}} +|{{#tableCellContent}}{{^optional}}true{{/optional}}{{#optional}}false{{/optional}}{{/tableCellContent}} +|{{#tableCellContent}}{{description}}{{/tableCellContent}} +{{/fields}} +|=== \ No newline at end of file diff --git a/src/test/resources/org/springframework/restdocs/templates/response-fields.snippet b/src/test/resources/org/springframework/restdocs/templates/response-fields.snippet new file mode 100644 index 00000000..154e137c --- /dev/null +++ b/src/test/resources/org/springframework/restdocs/templates/response-fields.snippet @@ -0,0 +1,10 @@ +|=== +|Name|Type|Required|Description + +{{#fields}} +|{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}} +|{{#tableCellContent}}`+{{type}}+`{{/tableCellContent}} +|{{#tableCellContent}}{{^optional}}true{{/optional}}{{#optional}}false{{/optional}}{{/tableCellContent}} +|{{#tableCellContent}}{{description}}{{/tableCellContent}} +{{/fields}} +|=== \ No newline at end of file