diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml new file mode 100644 index 0000000..c9608e6 --- /dev/null +++ b/.github/workflows/CI.yml @@ -0,0 +1,38 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. +# This workflow will build a package using Gradle and then publish it to GitHub packages when a release is created +# For more information see: https://github.com/actions/setup-java/blob/main/docs/advanced-usage.md#Publishing-using-gradle + +name: Gradle Package + +on: + pull_request: + branches: + - "master" + - "develop" + +jobs: + build: + + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: checkout + uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + + - name: build and test + run: | + chmod +x gradlew + ./gradlew build diff --git a/.gitignore b/.gitignore index 4125023..7c43360 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,8 @@ build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ - .env +local.env ### STS ### .apt_generated @@ -37,3 +37,5 @@ out/ ### VS Code ### .vscode/ + +src/test/resources/insert-search-problem.sql diff --git a/Dockerfile b/Dockerfile index 780a42f..a90d3c3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,17 +2,6 @@ FROM openjdk:17 ARG JAR_FILE=build/libs/*.jar COPY ${JAR_FILE} app.jar -ARG apm_agent=apm-agent/*.jar -COPY ${apm_agent} apm-agent.jar - -ARG PROFILE=dev ENV SPRING_PROFILES_ACTIVE=${PROFILE} -ENTRYPOINT ["java", \ -"-javaagent:/apm-agent.jar", \ -"-Delastic.apm.server_urls=http://114.70.23.79:8200", \ -"-Delastic.apm.service_name=moplus-apm-agent", \ -"-Delastic.apm.application_packages=com.server", \ -"-Delastic.apm.environment=dev", \ -"-jar", \ -"/app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/Dockerfile.prod b/Dockerfile.prod index 5f66081..a90d3c3 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -2,17 +2,6 @@ FROM openjdk:17 ARG JAR_FILE=build/libs/*.jar COPY ${JAR_FILE} app.jar -ARG apm_agent=apm-agent/*.jar -COPY ${apm_agent} apm-agent.jar - -ARG PROFILE=dev ENV SPRING_PROFILES_ACTIVE=${PROFILE} -ENTRYPOINT ["java", \ -"-javaagent:/apm-agent.jar", \ -"-Delastic.apm.server_urls=http://114.70.23.79:8200", \ -"-Delastic.apm.service_name=moplus-apm-agent", \ -"-Delastic.apm.application_packages=com.server", \ -"-Delastic.apm.environment=prod", \ -"-jar", \ -"/app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/README.md b/README.md index 78224e8..7dbe7f0 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,47 @@ +# 모플: 매일 3문항 수학 사고력 향상 서비스 +매일 **기출 문제 3문항**을 풀며, **수학 사고력 취약점을 진단, 처방, 교정**까지 한 번에 해결하는 **올인원 수학 사고력 향상 서비스**입니다. -![깃허브 배너](https://github.com/user-attachments/assets/e0f52d08-05f9-44ba-9a71-f20cb9d52743) +--- -
+## 🎯 사용자 서비스 기능 +
+ + + +
-

빠르게 받아보는 내 취약점 복습서, 모플

-

기능1: 빠르게 틀린 문제를 체크해서 내 점수와 등급을 받아보세요.

-

기능2: 입력한 오답문제를 바탕으로 복습서를 제공합니다.

-

기능3: 친구에게 내 결과를 공유해보세요.

+- **핵심 사고 과정 점검**: 각 기출 문항은 3개의 세부 문항으로 구성되어 있어, 취약점을 명확히 진단할 수 있습니다. +- **손해설 및 풀이 과정 제공**: 풀이 후, 문제 해결 과정에서 필요했던 핵심 사고력을 정리하여 개념 보완을 돕습니다. +- **오답 분석 및 맞춤 처방**: 세부 문항별 오답을 분석하고, 추가 학습이 필요한 개념을 처방합니다. +- **교정 학습 기능**: 틀린 문제와 유사한 문제를 다시 풀며, 취약한 사고 과정을 보완하고 사고력을 강화할 수 있습니다. -
+--- -

Admin 페이지

-

Thymeleaf를 통해 구현한 어드민 페이지로 모의고사에 대한 정보, 정답, 정답률, 문제 이미지를 업로드할 수 있습니다.

-
- image - image - image - image +## 🛠 어드민 서비스 기능 +
+ + +
+- **문항 및 세부 문항 등록**: 새 문제와 세부 문항을 손쉽게 등록할 수 있습니다. +- **세트 구성 및 일정 관리**: 여러 문항을 세트로 묶어 원하는 날짜에 발행 가능합니다. +- **자동 배포 기능**: 발행된 세트는 사용자에게 **"오늘의 문제"**로 제공됩니다. -
+--- -## 서버 팀원 소개 -|[BE 개발자 박세준](https://github.com/sejoon00)| -|:--------:| -|| +## 🏗 Architecture +
+ +
+- 서비스의 **안정적인 운영을 위해 prod(운영)과 dev(개발) 서버를 분리**하여 배포 및 관리하고 있습니다. + +--- + +## 서버 팀원 소개 +||| +|:--------:|:--------:| +|[BE 박세준](https://github.com/sejoon00)|[BE 홍석범](https://github.com/seokbeom00)| +
\ No newline at end of file diff --git a/build.gradle b/build.gradle index bdbfc70..4409a35 100644 --- a/build.gradle +++ b/build.gradle @@ -23,6 +23,7 @@ repositories { mavenCentral() } + dependencies { implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' @@ -33,6 +34,10 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + // Spring Security + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.security:spring-security-test' + //db-h2 implementation 'com.h2database:h2' testImplementation 'com.h2database:h2' @@ -44,6 +49,28 @@ dependencies { //s3 implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // validator + implementation 'commons-validator:commons-validator:1.7' + + // Map Struct + implementation 'org.mapstruct:mapstruct:1.6.3' + annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.3' + annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0' + + // JPA + implementation 'jakarta.persistence:jakarta.persistence-api:3.1.0' + implementation 'jakarta.annotation:jakarta.annotation-api:2.1.1' + + // QueryDSL + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" } tasks.named('test') { diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index b3c514e..37eb094 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -22,6 +22,11 @@ services: - CLOUD_AWS_REGION_STATIC=${AWS_REGION} - CLOUD_AWS_REGION_AUTO=false - CLOUD_AWS_STACK_AUTO=false + - JWT_ACCESS_TOKEN_SECRET=${JWT_ACCESS_TOKEN_SECRET} + - JWT_REFRESH_TOKEN_SECRET=${JWT_REFRESH_TOKEN_SECRET} + - JWT_ACCESS_TOKEN_EXPIRATION_TIME=${JWT_ACCESS_TOKEN_EXPIRATION_TIME} # 기본값: 2시간 + - JWT_REFRESH_TOKEN_EXPIRATION_TIME=${JWT_REFRESH_TOKEN_EXPIRATION_TIME} # 기본값: 7일 + - JWT_ISSUER=${JWT_ISSUER} depends_on: - mysql diff --git a/src/main/generated/com/moplus/moplus_server/domain/concept/domain/QConceptTag.java b/src/main/generated/com/moplus/moplus_server/domain/concept/domain/QConceptTag.java new file mode 100644 index 0000000..454956c --- /dev/null +++ b/src/main/generated/com/moplus/moplus_server/domain/concept/domain/QConceptTag.java @@ -0,0 +1,47 @@ +package com.moplus.moplus_server.domain.concept.domain; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QConceptTag is a Querydsl query type for ConceptTag + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QConceptTag extends EntityPathBase { + + private static final long serialVersionUID = 652954745L; + + public static final QConceptTag conceptTag = new QConceptTag("conceptTag"); + + public final com.moplus.moplus_server.global.common.QBaseEntity _super = new com.moplus.moplus_server.global.common.QBaseEntity(this); + + //inherited + public final DateTimePath createdDate = _super.createdDate; + + public final NumberPath id = createNumber("id", Long.class); + + public final StringPath name = createString("name"); + + //inherited + public final DateTimePath updatedDate = _super.updatedDate; + + public QConceptTag(String variable) { + super(ConceptTag.class, forVariable(variable)); + } + + public QConceptTag(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QConceptTag(PathMetadata metadata) { + super(ConceptTag.class, metadata); + } + +} + diff --git a/src/main/generated/com/moplus/moplus_server/domain/member/domain/QMember.java b/src/main/generated/com/moplus/moplus_server/domain/member/domain/QMember.java new file mode 100644 index 0000000..ad5aeae --- /dev/null +++ b/src/main/generated/com/moplus/moplus_server/domain/member/domain/QMember.java @@ -0,0 +1,53 @@ +package com.moplus.moplus_server.domain.member.domain; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QMember is a Querydsl query type for Member + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QMember extends EntityPathBase { + + private static final long serialVersionUID = -705761779L; + + public static final QMember member = new QMember("member1"); + + public final com.moplus.moplus_server.global.common.QBaseEntity _super = new com.moplus.moplus_server.global.common.QBaseEntity(this); + + //inherited + public final DateTimePath createdDate = _super.createdDate; + + public final StringPath email = createString("email"); + + public final NumberPath id = createNumber("id", Long.class); + + public final StringPath name = createString("name"); + + public final StringPath password = createString("password"); + + public final EnumPath role = createEnum("role", MemberRole.class); + + //inherited + public final DateTimePath updatedDate = _super.updatedDate; + + public QMember(String variable) { + super(Member.class, forVariable(variable)); + } + + public QMember(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QMember(PathMetadata metadata) { + super(Member.class, metadata); + } + +} + diff --git a/src/main/generated/com/moplus/moplus_server/domain/problem/domain/QAnswer.java b/src/main/generated/com/moplus/moplus_server/domain/problem/domain/QAnswer.java new file mode 100644 index 0000000..cdda3fe --- /dev/null +++ b/src/main/generated/com/moplus/moplus_server/domain/problem/domain/QAnswer.java @@ -0,0 +1,37 @@ +package com.moplus.moplus_server.domain.problem.domain; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QAnswer is a Querydsl query type for Answer + */ +@Generated("com.querydsl.codegen.DefaultEmbeddableSerializer") +public class QAnswer extends BeanPath { + + private static final long serialVersionUID = 983834524L; + + public static final QAnswer answer = new QAnswer("answer"); + + public final StringPath value = createString("value"); + + public QAnswer(String variable) { + super(Answer.class, forVariable(variable)); + } + + public QAnswer(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QAnswer(PathMetadata metadata) { + super(Answer.class, metadata); + } + +} + diff --git a/src/main/generated/com/moplus/moplus_server/domain/problem/domain/childProblem/QChildProblem.java b/src/main/generated/com/moplus/moplus_server/domain/problem/domain/childProblem/QChildProblem.java new file mode 100644 index 0000000..193076d --- /dev/null +++ b/src/main/generated/com/moplus/moplus_server/domain/problem/domain/childProblem/QChildProblem.java @@ -0,0 +1,65 @@ +package com.moplus.moplus_server.domain.problem.domain.childProblem; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QChildProblem is a Querydsl query type for ChildProblem + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QChildProblem extends EntityPathBase { + + private static final long serialVersionUID = 1030139824L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QChildProblem childProblem = new QChildProblem("childProblem"); + + public final com.moplus.moplus_server.global.common.QBaseEntity _super = new com.moplus.moplus_server.global.common.QBaseEntity(this); + + public final com.moplus.moplus_server.domain.problem.domain.QAnswer answer; + + public final EnumPath answerType = createEnum("answerType", com.moplus.moplus_server.domain.problem.domain.problem.AnswerType.class); + + public final SetPath> conceptTagIds = this.>createSet("conceptTagIds", Long.class, NumberPath.class, PathInits.DIRECT2); + + //inherited + public final DateTimePath createdDate = _super.createdDate; + + public final NumberPath id = createNumber("id", Long.class); + + public final StringPath imageUrl = createString("imageUrl"); + + //inherited + public final DateTimePath updatedDate = _super.updatedDate; + + public QChildProblem(String variable) { + this(ChildProblem.class, forVariable(variable), INITS); + } + + public QChildProblem(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QChildProblem(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QChildProblem(PathMetadata metadata, PathInits inits) { + this(ChildProblem.class, metadata, inits); + } + + public QChildProblem(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.answer = inits.isInitialized("answer") ? new com.moplus.moplus_server.domain.problem.domain.QAnswer(forProperty("answer")) : null; + } + +} + diff --git a/src/main/generated/com/moplus/moplus_server/domain/problem/domain/practiceTest/QPracticeTestTag.java b/src/main/generated/com/moplus/moplus_server/domain/problem/domain/practiceTest/QPracticeTestTag.java new file mode 100644 index 0000000..83588ac --- /dev/null +++ b/src/main/generated/com/moplus/moplus_server/domain/problem/domain/practiceTest/QPracticeTestTag.java @@ -0,0 +1,47 @@ +package com.moplus.moplus_server.domain.problem.domain.practiceTest; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QPracticeTestTag is a Querydsl query type for PracticeTestTag + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QPracticeTestTag extends EntityPathBase { + + private static final long serialVersionUID = -2120162934L; + + public static final QPracticeTestTag practiceTestTag = new QPracticeTestTag("practiceTestTag"); + + public final StringPath area = createString("area"); + + public final NumberPath id = createNumber("id", Long.class); + + public final NumberPath month = createNumber("month", Integer.class); + + public final StringPath name = createString("name"); + + public final EnumPath subject = createEnum("subject", Subject.class); + + public final NumberPath year = createNumber("year", Integer.class); + + public QPracticeTestTag(String variable) { + super(PracticeTestTag.class, forVariable(variable)); + } + + public QPracticeTestTag(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QPracticeTestTag(PathMetadata metadata) { + super(PracticeTestTag.class, metadata); + } + +} + diff --git a/src/main/generated/com/moplus/moplus_server/domain/problem/domain/problem/QDifficulty.java b/src/main/generated/com/moplus/moplus_server/domain/problem/domain/problem/QDifficulty.java new file mode 100644 index 0000000..ccd4d1b --- /dev/null +++ b/src/main/generated/com/moplus/moplus_server/domain/problem/domain/problem/QDifficulty.java @@ -0,0 +1,37 @@ +package com.moplus.moplus_server.domain.problem.domain.problem; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QDifficulty is a Querydsl query type for Difficulty + */ +@Generated("com.querydsl.codegen.DefaultEmbeddableSerializer") +public class QDifficulty extends BeanPath { + + private static final long serialVersionUID = 175172936L; + + public static final QDifficulty difficulty1 = new QDifficulty("difficulty1"); + + public final NumberPath difficulty = createNumber("difficulty", Integer.class); + + public QDifficulty(String variable) { + super(Difficulty.class, forVariable(variable)); + } + + public QDifficulty(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QDifficulty(PathMetadata metadata) { + super(Difficulty.class, metadata); + } + +} + diff --git a/src/main/generated/com/moplus/moplus_server/domain/problem/domain/problem/QProblem.java b/src/main/generated/com/moplus/moplus_server/domain/problem/domain/problem/QProblem.java new file mode 100644 index 0000000..c06ac88 --- /dev/null +++ b/src/main/generated/com/moplus/moplus_server/domain/problem/domain/problem/QProblem.java @@ -0,0 +1,99 @@ +package com.moplus.moplus_server.domain.problem.domain.problem; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QProblem is a Querydsl query type for Problem + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QProblem extends EntityPathBase { + + private static final long serialVersionUID = -1319796686L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QProblem problem = new QProblem("problem"); + + public final com.moplus.moplus_server.global.common.QBaseEntity _super = new com.moplus.moplus_server.global.common.QBaseEntity(this); + + public final com.moplus.moplus_server.domain.problem.domain.QAnswer answer; + + public final EnumPath answerType = createEnum("answerType", AnswerType.class); + + public final ListPath childProblems = this.createList("childProblems", com.moplus.moplus_server.domain.problem.domain.childProblem.ChildProblem.class, com.moplus.moplus_server.domain.problem.domain.childProblem.QChildProblem.class, PathInits.DIRECT2); + + public final SetPath> conceptTagIds = this.>createSet("conceptTagIds", Long.class, NumberPath.class, PathInits.DIRECT2); + + //inherited + public final DateTimePath createdDate = _super.createdDate; + + public final QDifficulty difficulty; + + public final NumberPath id = createNumber("id", Long.class); + + public final BooleanPath isConfirmed = createBoolean("isConfirmed"); + + public final StringPath mainAnalysisImageUrl = createString("mainAnalysisImageUrl"); + + public final StringPath mainHandwritingExplanationImageUrl = createString("mainHandwritingExplanationImageUrl"); + + public final StringPath mainProblemImageUrl = createString("mainProblemImageUrl"); + + public final StringPath memo = createString("memo"); + + public final NumberPath number = createNumber("number", Integer.class); + + public final NumberPath practiceTestId = createNumber("practiceTestId", Long.class); + + public final ListPath prescriptionImageUrls = this.createList("prescriptionImageUrls", String.class, StringPath.class, PathInits.DIRECT2); + + public final QProblemCustomId problemCustomId; + + public final EnumPath problemType = createEnum("problemType", ProblemType.class); + + public final StringPath readingTipImageUrl = createString("readingTipImageUrl"); + + public final QRecommendedTime recommendedTime; + + public final StringPath seniorTipImageUrl = createString("seniorTipImageUrl"); + + public final QTitle title; + + //inherited + public final DateTimePath updatedDate = _super.updatedDate; + + public QProblem(String variable) { + this(Problem.class, forVariable(variable), INITS); + } + + public QProblem(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QProblem(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QProblem(PathMetadata metadata, PathInits inits) { + this(Problem.class, metadata, inits); + } + + public QProblem(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.answer = inits.isInitialized("answer") ? new com.moplus.moplus_server.domain.problem.domain.QAnswer(forProperty("answer")) : null; + this.difficulty = inits.isInitialized("difficulty") ? new QDifficulty(forProperty("difficulty")) : null; + this.problemCustomId = inits.isInitialized("problemCustomId") ? new QProblemCustomId(forProperty("problemCustomId")) : null; + this.recommendedTime = inits.isInitialized("recommendedTime") ? new QRecommendedTime(forProperty("recommendedTime")) : null; + this.title = inits.isInitialized("title") ? new QTitle(forProperty("title")) : null; + } + +} + diff --git a/src/main/generated/com/moplus/moplus_server/domain/problem/domain/problem/QProblemCustomId.java b/src/main/generated/com/moplus/moplus_server/domain/problem/domain/problem/QProblemCustomId.java new file mode 100644 index 0000000..896d9cf --- /dev/null +++ b/src/main/generated/com/moplus/moplus_server/domain/problem/domain/problem/QProblemCustomId.java @@ -0,0 +1,37 @@ +package com.moplus.moplus_server.domain.problem.domain.problem; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QProblemCustomId is a Querydsl query type for ProblemCustomId + */ +@Generated("com.querydsl.codegen.DefaultEmbeddableSerializer") +public class QProblemCustomId extends BeanPath { + + private static final long serialVersionUID = -517009730L; + + public static final QProblemCustomId problemCustomId = new QProblemCustomId("problemCustomId"); + + public final StringPath id = createString("id"); + + public QProblemCustomId(String variable) { + super(ProblemCustomId.class, forVariable(variable)); + } + + public QProblemCustomId(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QProblemCustomId(PathMetadata metadata) { + super(ProblemCustomId.class, metadata); + } + +} + diff --git a/src/main/generated/com/moplus/moplus_server/domain/problem/domain/problem/QRecommendedTime.java b/src/main/generated/com/moplus/moplus_server/domain/problem/domain/problem/QRecommendedTime.java new file mode 100644 index 0000000..2ef9140 --- /dev/null +++ b/src/main/generated/com/moplus/moplus_server/domain/problem/domain/problem/QRecommendedTime.java @@ -0,0 +1,39 @@ +package com.moplus.moplus_server.domain.problem.domain.problem; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QRecommendedTime is a Querydsl query type for RecommendedTime + */ +@Generated("com.querydsl.codegen.DefaultEmbeddableSerializer") +public class QRecommendedTime extends BeanPath { + + private static final long serialVersionUID = -1102611877L; + + public static final QRecommendedTime recommendedTime = new QRecommendedTime("recommendedTime"); + + public final NumberPath minute = createNumber("minute", Integer.class); + + public final NumberPath second = createNumber("second", Integer.class); + + public QRecommendedTime(String variable) { + super(RecommendedTime.class, forVariable(variable)); + } + + public QRecommendedTime(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QRecommendedTime(PathMetadata metadata) { + super(RecommendedTime.class, metadata); + } + +} + diff --git a/src/main/generated/com/moplus/moplus_server/domain/problem/domain/problem/QTitle.java b/src/main/generated/com/moplus/moplus_server/domain/problem/domain/problem/QTitle.java new file mode 100644 index 0000000..4c4cdd1 --- /dev/null +++ b/src/main/generated/com/moplus/moplus_server/domain/problem/domain/problem/QTitle.java @@ -0,0 +1,37 @@ +package com.moplus.moplus_server.domain.problem.domain.problem; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QTitle is a Querydsl query type for Title + */ +@Generated("com.querydsl.codegen.DefaultEmbeddableSerializer") +public class QTitle extends BeanPath { + + private static final long serialVersionUID = 42281131L; + + public static final QTitle title1 = new QTitle("title1"); + + public final StringPath title = createString("title"); + + public QTitle(String variable) { + super(Title.class, forVariable(variable)); + } + + public QTitle(Path<? extends Title> path) { + super(path.getType(), path.getMetadata()); + } + + public QTitle(PathMetadata metadata) { + super(Title.class, metadata); + } + +} + diff --git a/src/main/generated/com/moplus/moplus_server/domain/problem/service/mapper/ChildProblemMapperImpl.java b/src/main/generated/com/moplus/moplus_server/domain/problem/service/mapper/ChildProblemMapperImpl.java new file mode 100644 index 0000000..e682008 --- /dev/null +++ b/src/main/generated/com/moplus/moplus_server/domain/problem/service/mapper/ChildProblemMapperImpl.java @@ -0,0 +1,57 @@ +package com.moplus.moplus_server.domain.problem.service.mapper; + +import com.moplus.moplus_server.domain.problem.domain.childProblem.ChildProblem; +import com.moplus.moplus_server.domain.problem.dto.request.ChildProblemPostRequest; +import com.moplus.moplus_server.domain.problem.dto.request.ChildProblemUpdateRequest; +import java.util.LinkedHashSet; +import java.util.Set; +import javax.annotation.processing.Generated; +import org.springframework.stereotype.Component; + +@Generated( + value = "org.mapstruct.ap.MappingProcessor", + date = "2025-02-21T03:13:14+0900", + comments = "version: 1.6.3, compiler: javac, environment: Java 17.0.10 (JetBrains s.r.o.)" +) +@Component +public class ChildProblemMapperImpl implements ChildProblemMapper { + + @Override + public ChildProblem from(ChildProblemPostRequest request) { + if ( request == null ) { + return null; + } + + ChildProblem.ChildProblemBuilder childProblem = ChildProblem.builder(); + + childProblem.imageUrl( request.imageUrl() ); + childProblem.answerType( request.answerType() ); + childProblem.answer( request.answer() ); + Set<Long> set = request.conceptTagIds(); + if ( set != null ) { + childProblem.conceptTagIds( new LinkedHashSet<Long>( set ) ); + } + + return childProblem.build(); + } + + @Override + public ChildProblem from(ChildProblemUpdateRequest request) { + if ( request == null ) { + return null; + } + + ChildProblem.ChildProblemBuilder childProblem = ChildProblem.builder(); + + childProblem.id( request.childProblemId() ); + childProblem.imageUrl( request.imageUrl() ); + childProblem.answerType( request.answerType() ); + childProblem.answer( request.answer() ); + Set<Long> set = request.conceptTagIds(); + if ( set != null ) { + childProblem.conceptTagIds( new LinkedHashSet<Long>( set ) ); + } + + return childProblem.build(); + } +} diff --git a/src/main/generated/com/moplus/moplus_server/domain/problem/service/mapper/ProblemMapperImpl.java b/src/main/generated/com/moplus/moplus_server/domain/problem/service/mapper/ProblemMapperImpl.java new file mode 100644 index 0000000..4007f06 --- /dev/null +++ b/src/main/generated/com/moplus/moplus_server/domain/problem/service/mapper/ProblemMapperImpl.java @@ -0,0 +1,93 @@ +package com.moplus.moplus_server.domain.problem.service.mapper; + +import com.moplus.moplus_server.domain.problem.domain.practiceTest.PracticeTestTag; +import com.moplus.moplus_server.domain.problem.domain.problem.Problem; +import com.moplus.moplus_server.domain.problem.domain.problem.ProblemCustomId; +import com.moplus.moplus_server.domain.problem.domain.problem.ProblemType; +import com.moplus.moplus_server.domain.problem.dto.request.ProblemPostRequest; +import com.moplus.moplus_server.domain.problem.dto.request.ProblemUpdateRequest; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import javax.annotation.processing.Generated; +import org.springframework.stereotype.Component; + +@Generated( + value = "org.mapstruct.ap.MappingProcessor", + date = "2025-02-21T03:13:14+0900", + comments = "version: 1.6.3, compiler: javac, environment: Java 17.0.10 (JetBrains s.r.o.)" +) +@Component +public class ProblemMapperImpl implements ProblemMapper { + + @Override + public Problem from(ProblemPostRequest request, ProblemCustomId problemCustomId, PracticeTestTag practiceTestTag) { + if ( request == null && problemCustomId == null && practiceTestTag == null ) { + return null; + } + + Problem.ProblemBuilder problem = Problem.builder(); + + if ( request != null ) { + problem.problemType( request.problemType() ); + problem.number( request.number() ); + } + problem.problemCustomId( problemCustomId ); + problem.practiceTestTag( practiceTestTag ); + + return problem.build(); + } + + @Override + public Problem from(ProblemUpdateRequest request, ProblemCustomId problemCustomId, PracticeTestTag practiceTestTag) { + if ( request == null && problemCustomId == null && practiceTestTag == null ) { + return null; + } + + Problem.ProblemBuilder problem = Problem.builder(); + + if ( request != null ) { + problem.recommendedMinute( request.recommendedMinute() ); + problem.recommendedSecond( request.recommendedSecond() ); + problem.answerType( request.answerType() ); + Set<Long> set = request.conceptTagIds(); + if ( set != null ) { + problem.conceptTagIds( new LinkedHashSet<Long>( set ) ); + } + problem.difficulty( request.difficulty() ); + problem.mainHandwritingExplanationImageUrl( request.mainHandwritingExplanationImageUrl() ); + List<String> list = request.prescriptionImageUrls(); + if ( list != null ) { + problem.prescriptionImageUrls( new ArrayList<String>( list ) ); + } + problem.seniorTipImageUrl( request.seniorTipImageUrl() ); + problem.readingTipImageUrl( request.readingTipImageUrl() ); + problem.mainAnalysisImageUrl( request.mainAnalysisImageUrl() ); + problem.mainProblemImageUrl( request.mainProblemImageUrl() ); + problem.memo( request.memo() ); + problem.answer( request.answer() ); + problem.title( request.title() ); + problem.problemType( request.problemType() ); + problem.number( request.number() ); + } + problem.problemCustomId( problemCustomId ); + problem.practiceTestTag( practiceTestTag ); + + return problem.build(); + } + + @Override + public Problem from(ProblemType problemType, ProblemCustomId problemCustomId) { + if ( problemType == null && problemCustomId == null ) { + return null; + } + + Problem.ProblemBuilder problem = Problem.builder(); + + problem.problemType( problemType ); + problem.problemCustomId( problemCustomId ); + + return problem.build(); + } +} diff --git a/src/main/generated/com/moplus/moplus_server/domain/problemset/domain/QProblemSet.java b/src/main/generated/com/moplus/moplus_server/domain/problemset/domain/QProblemSet.java new file mode 100644 index 0000000..dc1cf58 --- /dev/null +++ b/src/main/generated/com/moplus/moplus_server/domain/problemset/domain/QProblemSet.java @@ -0,0 +1,65 @@ +package com.moplus.moplus_server.domain.problemset.domain; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QProblemSet is a Querydsl query type for ProblemSet + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QProblemSet extends EntityPathBase<ProblemSet> { + + private static final long serialVersionUID = -499971265L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QProblemSet problemSet = new QProblemSet("problemSet"); + + public final com.moplus.moplus_server.global.common.QBaseEntity _super = new com.moplus.moplus_server.global.common.QBaseEntity(this); + + public final EnumPath<ProblemSetConfirmStatus> confirmStatus = createEnum("confirmStatus", ProblemSetConfirmStatus.class); + + //inherited + public final DateTimePath<java.time.LocalDateTime> createdDate = _super.createdDate; + + public final NumberPath<Long> id = createNumber("id", Long.class); + + public final BooleanPath isDeleted = createBoolean("isDeleted"); + + public final ListPath<Long, NumberPath<Long>> problemIds = this.<Long, NumberPath<Long>>createList("problemIds", Long.class, NumberPath.class, PathInits.DIRECT2); + + public final QTitle title; + + //inherited + public final DateTimePath<java.time.LocalDateTime> updatedDate = _super.updatedDate; + + public QProblemSet(String variable) { + this(ProblemSet.class, forVariable(variable), INITS); + } + + public QProblemSet(Path<? extends ProblemSet> path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QProblemSet(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QProblemSet(PathMetadata metadata, PathInits inits) { + this(ProblemSet.class, metadata, inits); + } + + public QProblemSet(Class<? extends ProblemSet> type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.title = inits.isInitialized("title") ? new QTitle(forProperty("title")) : null; + } + +} + diff --git a/src/main/generated/com/moplus/moplus_server/domain/problemset/domain/QTitle.java b/src/main/generated/com/moplus/moplus_server/domain/problemset/domain/QTitle.java new file mode 100644 index 0000000..d6ad4db --- /dev/null +++ b/src/main/generated/com/moplus/moplus_server/domain/problemset/domain/QTitle.java @@ -0,0 +1,37 @@ +package com.moplus.moplus_server.domain.problemset.domain; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QTitle is a Querydsl query type for Title + */ +@Generated("com.querydsl.codegen.DefaultEmbeddableSerializer") +public class QTitle extends BeanPath<Title> { + + private static final long serialVersionUID = -324250916L; + + public static final QTitle title = new QTitle("title"); + + public final StringPath value = createString("value"); + + public QTitle(String variable) { + super(Title.class, forVariable(variable)); + } + + public QTitle(Path<? extends Title> path) { + super(path.getType(), path.getMetadata()); + } + + public QTitle(PathMetadata metadata) { + super(Title.class, metadata); + } + +} + diff --git a/src/main/generated/com/moplus/moplus_server/domain/publish/domain/QPublish.java b/src/main/generated/com/moplus/moplus_server/domain/publish/domain/QPublish.java new file mode 100644 index 0000000..41676a4 --- /dev/null +++ b/src/main/generated/com/moplus/moplus_server/domain/publish/domain/QPublish.java @@ -0,0 +1,49 @@ +package com.moplus.moplus_server.domain.publish.domain; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QPublish is a Querydsl query type for Publish + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QPublish extends EntityPathBase<Publish> { + + private static final long serialVersionUID = 1565569153L; + + public static final QPublish publish = new QPublish("publish"); + + public final com.moplus.moplus_server.global.common.QBaseEntity _super = new com.moplus.moplus_server.global.common.QBaseEntity(this); + + //inherited + public final DateTimePath<java.time.LocalDateTime> createdDate = _super.createdDate; + + public final NumberPath<Long> id = createNumber("id", Long.class); + + public final NumberPath<Long> problemSetId = createNumber("problemSetId", Long.class); + + public final DatePath<java.time.LocalDate> publishedDate = createDate("publishedDate", java.time.LocalDate.class); + + //inherited + public final DateTimePath<java.time.LocalDateTime> updatedDate = _super.updatedDate; + + public QPublish(String variable) { + super(Publish.class, forVariable(variable)); + } + + public QPublish(Path<? extends Publish> path) { + super(path.getType(), path.getMetadata()); + } + + public QPublish(PathMetadata metadata) { + super(Publish.class, metadata); + } + +} + diff --git a/src/main/generated/com/moplus/moplus_server/domain/v0/DetailResultApplication/entity/QDetailResultApplication.java b/src/main/generated/com/moplus/moplus_server/domain/v0/DetailResultApplication/entity/QDetailResultApplication.java new file mode 100644 index 0000000..9ff44d0 --- /dev/null +++ b/src/main/generated/com/moplus/moplus_server/domain/v0/DetailResultApplication/entity/QDetailResultApplication.java @@ -0,0 +1,51 @@ +package com.moplus.moplus_server.domain.v0.DetailResultApplication.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QDetailResultApplication is a Querydsl query type for DetailResultApplication + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QDetailResultApplication extends EntityPathBase<DetailResultApplication> { + + private static final long serialVersionUID = 215702330L; + + public static final QDetailResultApplication detailResultApplication = new QDetailResultApplication("detailResultApplication"); + + public final com.moplus.moplus_server.global.common.QBaseEntity _super = new com.moplus.moplus_server.global.common.QBaseEntity(this); + + //inherited + public final DateTimePath<java.time.LocalDateTime> createdDate = _super.createdDate; + + public final NumberPath<Long> id = createNumber("id", Long.class); + + public final StringPath name = createString("name"); + + public final StringPath phoneNumber = createString("phoneNumber"); + + public final NumberPath<Long> testResultId = createNumber("testResultId", Long.class); + + //inherited + public final DateTimePath<java.time.LocalDateTime> updatedDate = _super.updatedDate; + + public QDetailResultApplication(String variable) { + super(DetailResultApplication.class, forVariable(variable)); + } + + public QDetailResultApplication(Path<? extends DetailResultApplication> path) { + super(path.getType(), path.getMetadata()); + } + + public QDetailResultApplication(PathMetadata metadata) { + super(DetailResultApplication.class, metadata); + } + +} + diff --git a/src/main/generated/com/moplus/moplus_server/domain/v0/TestResult/entity/QEstimatedRating.java b/src/main/generated/com/moplus/moplus_server/domain/v0/TestResult/entity/QEstimatedRating.java new file mode 100644 index 0000000..7dbaf22 --- /dev/null +++ b/src/main/generated/com/moplus/moplus_server/domain/v0/TestResult/entity/QEstimatedRating.java @@ -0,0 +1,43 @@ +package com.moplus.moplus_server.domain.v0.TestResult.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QEstimatedRating is a Querydsl query type for EstimatedRating + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QEstimatedRating extends EntityPathBase<EstimatedRating> { + + private static final long serialVersionUID = -1923088138L; + + public static final QEstimatedRating estimatedRating1 = new QEstimatedRating("estimatedRating1"); + + public final NumberPath<Integer> estimatedRating = createNumber("estimatedRating", Integer.class); + + public final NumberPath<Long> id = createNumber("id", Long.class); + + public final StringPath ratingProvider = createString("ratingProvider"); + + public final NumberPath<Long> testResultId = createNumber("testResultId", Long.class); + + public QEstimatedRating(String variable) { + super(EstimatedRating.class, forVariable(variable)); + } + + public QEstimatedRating(Path<? extends EstimatedRating> path) { + super(path.getType(), path.getMetadata()); + } + + public QEstimatedRating(PathMetadata metadata) { + super(EstimatedRating.class, metadata); + } + +} + diff --git a/src/main/generated/com/moplus/moplus_server/domain/v0/TestResult/entity/QIncorrectProblem.java b/src/main/generated/com/moplus/moplus_server/domain/v0/TestResult/entity/QIncorrectProblem.java new file mode 100644 index 0000000..9f3b995 --- /dev/null +++ b/src/main/generated/com/moplus/moplus_server/domain/v0/TestResult/entity/QIncorrectProblem.java @@ -0,0 +1,71 @@ +package com.moplus.moplus_server.domain.v0.TestResult.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QIncorrectProblem is a Querydsl query type for IncorrectProblem + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QIncorrectProblem extends EntityPathBase<IncorrectProblem> { + + private static final long serialVersionUID = 1019670237L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QIncorrectProblem incorrectProblem = new QIncorrectProblem("incorrectProblem"); + + public final com.moplus.moplus_server.global.common.QBaseEntity _super = new com.moplus.moplus_server.global.common.QBaseEntity(this); + + public final NumberPath<Double> correctRate = createNumber("correctRate", Double.class); + + //inherited + public final DateTimePath<java.time.LocalDateTime> createdDate = _super.createdDate; + + public final NumberPath<Long> id = createNumber("id", Long.class); + + public final StringPath incorrectAnswer = createString("incorrectAnswer"); + + public final NumberPath<Integer> point = createNumber("point", Integer.class); + + public final NumberPath<Long> practiceTestId = createNumber("practiceTestId", Long.class); + + public final NumberPath<Long> problemId = createNumber("problemCustomId", Long.class); + + public final StringPath problemNumber = createString("problemNumber"); + + public final QTestResult testResult; + + //inherited + public final DateTimePath<java.time.LocalDateTime> updatedDate = _super.updatedDate; + + public QIncorrectProblem(String variable) { + this(IncorrectProblem.class, forVariable(variable), INITS); + } + + public QIncorrectProblem(Path<? extends IncorrectProblem> path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QIncorrectProblem(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QIncorrectProblem(PathMetadata metadata, PathInits inits) { + this(IncorrectProblem.class, metadata, inits); + } + + public QIncorrectProblem(Class<? extends IncorrectProblem> type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.testResult = inits.isInitialized("testResult") ? new QTestResult(forProperty("testResult")) : null; + } + +} + diff --git a/src/main/generated/com/moplus/moplus_server/domain/v0/TestResult/entity/QTestResult.java b/src/main/generated/com/moplus/moplus_server/domain/v0/TestResult/entity/QTestResult.java new file mode 100644 index 0000000..5f8fb8b --- /dev/null +++ b/src/main/generated/com/moplus/moplus_server/domain/v0/TestResult/entity/QTestResult.java @@ -0,0 +1,51 @@ +package com.moplus.moplus_server.domain.v0.TestResult.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QTestResult is a Querydsl query type for TestResult + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QTestResult extends EntityPathBase<TestResult> { + + private static final long serialVersionUID = -1200752334L; + + public static final QTestResult testResult = new QTestResult("testResult"); + + public final com.moplus.moplus_server.global.common.QBaseEntity _super = new com.moplus.moplus_server.global.common.QBaseEntity(this); + + //inherited + public final DateTimePath<java.time.LocalDateTime> createdDate = _super.createdDate; + + public final NumberPath<Long> id = createNumber("id", Long.class); + + public final NumberPath<Long> practiceTestId = createNumber("practiceTestId", Long.class); + + public final NumberPath<Integer> score = createNumber("score", Integer.class); + + public final ComparablePath<java.time.Duration> solvingTime = createComparable("solvingTime", java.time.Duration.class); + + //inherited + public final DateTimePath<java.time.LocalDateTime> updatedDate = _super.updatedDate; + + public QTestResult(String variable) { + super(TestResult.class, forVariable(variable)); + } + + public QTestResult(Path<? extends TestResult> path) { + super(path.getType(), path.getMetadata()); + } + + public QTestResult(PathMetadata metadata) { + super(TestResult.class, metadata); + } + +} + diff --git a/src/main/generated/com/moplus/moplus_server/domain/v0/practiceTest/domain/QPracticeTest.java b/src/main/generated/com/moplus/moplus_server/domain/v0/practiceTest/domain/QPracticeTest.java new file mode 100644 index 0000000..38ac923 --- /dev/null +++ b/src/main/generated/com/moplus/moplus_server/domain/v0/practiceTest/domain/QPracticeTest.java @@ -0,0 +1,61 @@ +package com.moplus.moplus_server.domain.v0.practiceTest.domain; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QPracticeTest is a Querydsl query type for PracticeTest + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QPracticeTest extends EntityPathBase<PracticeTest> { + + private static final long serialVersionUID = -700271987L; + + public static final QPracticeTest practiceTest = new QPracticeTest("practiceTest"); + + public final com.moplus.moplus_server.global.common.QBaseEntity _super = new com.moplus.moplus_server.global.common.QBaseEntity(this); + + public final ComparablePath<java.time.Duration> averageSolvingTime = createComparable("averageSolvingTime", java.time.Duration.class); + + //inherited + public final DateTimePath<java.time.LocalDateTime> createdDate = _super.createdDate; + + public final NumberPath<Long> id = createNumber("id", Long.class); + + public final StringPath name = createString("name"); + + public final StringPath provider = createString("provider"); + + public final NumberPath<Integer> publicationYear = createNumber("publicationYear", Integer.class); + + public final StringPath round = createString("round"); + + public final NumberPath<Integer> solvesCount = createNumber("solvesCount", Integer.class); + + public final EnumPath<com.moplus.moplus_server.domain.problem.domain.practiceTest.Subject> subject = createEnum("subject", com.moplus.moplus_server.domain.problem.domain.practiceTest.Subject.class); + + //inherited + public final DateTimePath<java.time.LocalDateTime> updatedDate = _super.updatedDate; + + public final NumberPath<Long> viewCount = createNumber("viewCount", Long.class); + + public QPracticeTest(String variable) { + super(PracticeTest.class, forVariable(variable)); + } + + public QPracticeTest(Path<? extends PracticeTest> path) { + super(path.getType(), path.getMetadata()); + } + + public QPracticeTest(PathMetadata metadata) { + super(PracticeTest.class, metadata); + } + +} + diff --git a/src/main/generated/com/moplus/moplus_server/domain/v0/practiceTest/domain/QProblemForTest.java b/src/main/generated/com/moplus/moplus_server/domain/v0/practiceTest/domain/QProblemForTest.java new file mode 100644 index 0000000..031ef66 --- /dev/null +++ b/src/main/generated/com/moplus/moplus_server/domain/v0/practiceTest/domain/QProblemForTest.java @@ -0,0 +1,82 @@ +package com.moplus.moplus_server.domain.v0.practiceTest.domain; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QProblemForTest is a Querydsl query type for ProblemForTest + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QProblemForTest extends EntityPathBase<ProblemForTest> { + + private static final long serialVersionUID = 159101820L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QProblemForTest problemForTest = new QProblemForTest("problemForTest"); + + public final com.moplus.moplus_server.global.common.QBaseEntity _super = new com.moplus.moplus_server.global.common.QBaseEntity(this); + + public final StringPath answer = createString("answer"); + + public final EnumPath<AnswerFormat> answerFormat = createEnum("answerFormat", AnswerFormat.class); + + public final StringPath conceptType = createString("conceptType"); + + public final NumberPath<Double> correctRate = createNumber("correctRate", Double.class); + + //inherited + public final DateTimePath<java.time.LocalDateTime> createdDate = _super.createdDate; + + public final NumberPath<Long> id = createNumber("id", Long.class); + + public final QProblemImageForTest image; + + public final NumberPath<Long> incorrectNum = createNumber("incorrectNum", Long.class); + + public final NumberPath<Integer> point = createNumber("point", Integer.class); + + public final QPracticeTest practiceTest; + + public final StringPath problemNumber = createString("problemNumber"); + + public final EnumPath<ProblemRating> problemRating = createEnum("problemRating", ProblemRating.class); + + public final StringPath subunit = createString("subunit"); + + public final StringPath unit = createString("unit"); + + //inherited + public final DateTimePath<java.time.LocalDateTime> updatedDate = _super.updatedDate; + + public QProblemForTest(String variable) { + this(ProblemForTest.class, forVariable(variable), INITS); + } + + public QProblemForTest(Path<? extends ProblemForTest> path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QProblemForTest(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QProblemForTest(PathMetadata metadata, PathInits inits) { + this(ProblemForTest.class, metadata, inits); + } + + public QProblemForTest(Class<? extends ProblemForTest> type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.image = inits.isInitialized("image") ? new QProblemImageForTest(forProperty("image")) : null; + this.practiceTest = inits.isInitialized("practiceTest") ? new QPracticeTest(forProperty("practiceTest")) : null; + } + +} + diff --git a/src/main/generated/com/moplus/moplus_server/domain/v0/practiceTest/domain/QProblemImageForTest.java b/src/main/generated/com/moplus/moplus_server/domain/v0/practiceTest/domain/QProblemImageForTest.java new file mode 100644 index 0000000..67b69aa --- /dev/null +++ b/src/main/generated/com/moplus/moplus_server/domain/v0/practiceTest/domain/QProblemImageForTest.java @@ -0,0 +1,43 @@ +package com.moplus.moplus_server.domain.v0.practiceTest.domain; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QProblemImageForTest is a Querydsl query type for ProblemImageForTest + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QProblemImageForTest extends EntityPathBase<ProblemImageForTest> { + + private static final long serialVersionUID = 1499588927L; + + public static final QProblemImageForTest problemImageForTest = new QProblemImageForTest("problemImageForTest"); + + public final StringPath fileName = createString("fileName"); + + public final NumberPath<Long> id = createNumber("id", Long.class); + + public final StringPath imageUrl = createString("imageUrl"); + + public final NumberPath<Long> problemId = createNumber("problemCustomId", Long.class); + + public QProblemImageForTest(String variable) { + super(ProblemImageForTest.class, forVariable(variable)); + } + + public QProblemImageForTest(Path<? extends ProblemImageForTest> path) { + super(path.getType(), path.getMetadata()); + } + + public QProblemImageForTest(PathMetadata metadata) { + super(ProblemImageForTest.class, metadata); + } + +} + diff --git a/src/main/generated/com/moplus/moplus_server/domain/v0/practiceTest/domain/QRatingTable.java b/src/main/generated/com/moplus/moplus_server/domain/v0/practiceTest/domain/QRatingTable.java new file mode 100644 index 0000000..842309d --- /dev/null +++ b/src/main/generated/com/moplus/moplus_server/domain/v0/practiceTest/domain/QRatingTable.java @@ -0,0 +1,54 @@ +package com.moplus.moplus_server.domain.v0.practiceTest.domain; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QRatingTable is a Querydsl query type for RatingTable + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QRatingTable extends EntityPathBase<RatingTable> { + + private static final long serialVersionUID = -1985854959L; + + public static final QRatingTable ratingTable = new QRatingTable("ratingTable"); + + public final com.moplus.moplus_server.global.common.QBaseEntity _super = new com.moplus.moplus_server.global.common.QBaseEntity(this); + + //inherited + public final DateTimePath<java.time.LocalDateTime> createdDate = _super.createdDate; + + public final NumberPath<Long> id = createNumber("id", Long.class); + + public final NumberPath<Long> practiceTestId = createNumber("practiceTestId", Long.class); + + public final StringPath ratingProvider = createString("ratingProvider"); + + public final ListPath<RatingRow, SimplePath<RatingRow>> ratingRows = this.<RatingRow, SimplePath<RatingRow>>createList("ratingRows", RatingRow.class, SimplePath.class, PathInits.DIRECT2); + + public final EnumPath<com.moplus.moplus_server.domain.problem.domain.practiceTest.Subject> subject = createEnum("subject", com.moplus.moplus_server.domain.problem.domain.practiceTest.Subject.class); + + //inherited + public final DateTimePath<java.time.LocalDateTime> updatedDate = _super.updatedDate; + + public QRatingTable(String variable) { + super(RatingTable.class, forVariable(variable)); + } + + public QRatingTable(Path<? extends RatingTable> path) { + super(path.getType(), path.getMetadata()); + } + + public QRatingTable(PathMetadata metadata) { + super(RatingTable.class, metadata); + } + +} + diff --git a/src/main/generated/com/moplus/moplus_server/global/common/QBaseEntity.java b/src/main/generated/com/moplus/moplus_server/global/common/QBaseEntity.java new file mode 100644 index 0000000..8c5284b --- /dev/null +++ b/src/main/generated/com/moplus/moplus_server/global/common/QBaseEntity.java @@ -0,0 +1,39 @@ +package com.moplus.moplus_server.global.common; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QBaseEntity is a Querydsl query type for BaseEntity + */ +@Generated("com.querydsl.codegen.DefaultSupertypeSerializer") +public class QBaseEntity extends EntityPathBase<BaseEntity> { + + private static final long serialVersionUID = -1014955751L; + + public static final QBaseEntity baseEntity = new QBaseEntity("baseEntity"); + + public final DateTimePath<java.time.LocalDateTime> createdDate = createDateTime("createdDate", java.time.LocalDateTime.class); + + public final DateTimePath<java.time.LocalDateTime> updatedDate = createDateTime("updatedDate", java.time.LocalDateTime.class); + + public QBaseEntity(String variable) { + super(BaseEntity.class, forVariable(variable)); + } + + public QBaseEntity(Path<? extends BaseEntity> path) { + super(path.getType(), path.getMetadata()); + } + + public QBaseEntity(PathMetadata metadata) { + super(BaseEntity.class, metadata); + } + +} + diff --git a/src/main/java/com/moplus/moplus_server/MoplusServerApplication.java b/src/main/java/com/moplus/moplus_server/MoplusServerApplication.java index da5ce8f..f4f7565 100644 --- a/src/main/java/com/moplus/moplus_server/MoplusServerApplication.java +++ b/src/main/java/com/moplus/moplus_server/MoplusServerApplication.java @@ -1,14 +1,11 @@ package com.moplus.moplus_server; -import io.swagger.v3.oas.annotations.OpenAPIDefinition; -import io.swagger.v3.oas.annotations.servers.Server; import java.util.Arrays; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.core.env.Environment; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; -@OpenAPIDefinition(servers = {@Server(url = "https://dev.mopl.kr", description = "Default Server URL")}) @SpringBootApplication @EnableJpaAuditing public class MoplusServerApplication { diff --git a/src/main/java/com/moplus/moplus_server/domain/DetailResultApplication/dto/request/DetailResultApplicationPostRequest.java b/src/main/java/com/moplus/moplus_server/domain/DetailResultApplication/dto/request/DetailResultApplicationPostRequest.java deleted file mode 100644 index a94832e..0000000 --- a/src/main/java/com/moplus/moplus_server/domain/DetailResultApplication/dto/request/DetailResultApplicationPostRequest.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.moplus.moplus_server.domain.DetailResultApplication.dto.request; - -import com.moplus.moplus_server.domain.DetailResultApplication.entity.DetailResultApplication; - -public record DetailResultApplicationPostRequest( - Long testResultId, - String name, - String phoneNumber -) { - - public DetailResultApplication toEntity() { - return DetailResultApplication.builder() - .testResultId(testResultId) - .name(name) - .phoneNumber(phoneNumber) - .build(); - } -} diff --git a/src/main/java/com/moplus/moplus_server/domain/DetailResultApplication/dto/response/ProblemGetResponse.java b/src/main/java/com/moplus/moplus_server/domain/DetailResultApplication/dto/response/ProblemGetResponse.java deleted file mode 100644 index 4420a72..0000000 --- a/src/main/java/com/moplus/moplus_server/domain/DetailResultApplication/dto/response/ProblemGetResponse.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.moplus.moplus_server.domain.DetailResultApplication.dto.response; - -import com.moplus.moplus_server.domain.practiceTest.domain.Problem; -import com.moplus.moplus_server.domain.practiceTest.domain.ProblemImage; -import com.moplus.moplus_server.domain.practiceTest.domain.ProblemRating; -import lombok.Builder; - -@Builder -public record ProblemGetResponse( - String problemNumber, - String difficultLevel, - double correctRate, - String rating, - String imageUrl -) { - - public static ProblemGetResponse of( - Problem problem - ) { - ProblemRating problemRating = problem.getProblemRating(); - ProblemImage image = problem.getImage(); - return ProblemGetResponse.builder() - .problemNumber(problem.getProblemNumber()) - .difficultLevel(problemRating.getDifficultyLevel()) - .correctRate(problem.getCorrectRate()) - .rating(problemRating.getRating()) - .imageUrl(image.getImageUrl()) - .build(); - } -} diff --git a/src/main/java/com/moplus/moplus_server/domain/DetailResultApplication/respository/DetailResultApplicationRepository.java b/src/main/java/com/moplus/moplus_server/domain/DetailResultApplication/respository/DetailResultApplicationRepository.java deleted file mode 100644 index 17d4a08..0000000 --- a/src/main/java/com/moplus/moplus_server/domain/DetailResultApplication/respository/DetailResultApplicationRepository.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.moplus.moplus_server.domain.DetailResultApplication.respository; - -import com.moplus.moplus_server.domain.DetailResultApplication.entity.DetailResultApplication; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface DetailResultApplicationRepository extends JpaRepository<DetailResultApplication, Long> { - -} diff --git a/src/main/java/com/moplus/moplus_server/domain/TestResult/dto/request/IncorrectProblemPostRequest.java b/src/main/java/com/moplus/moplus_server/domain/TestResult/dto/request/IncorrectProblemPostRequest.java deleted file mode 100644 index 438f65a..0000000 --- a/src/main/java/com/moplus/moplus_server/domain/TestResult/dto/request/IncorrectProblemPostRequest.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.moplus.moplus_server.domain.TestResult.dto.request; - -import com.moplus.moplus_server.domain.TestResult.entity.IncorrectProblem; -import com.moplus.moplus_server.domain.practiceTest.domain.Problem; - -public record IncorrectProblemPostRequest( - String problemNumber, - String incorrectAnswer -) { - - public IncorrectProblem toEntity(Problem problem){ - return IncorrectProblem.builder() - .problemNumber(problemNumber) - .incorrectAnswer(incorrectAnswer) - .point(problem.getPoint()) - .problemId(problem.getId()) - .build(); - } -} diff --git a/src/main/java/com/moplus/moplus_server/domain/TestResult/dto/response/IncorrectProblemGetResponse.java b/src/main/java/com/moplus/moplus_server/domain/TestResult/dto/response/IncorrectProblemGetResponse.java deleted file mode 100644 index 153d0ea..0000000 --- a/src/main/java/com/moplus/moplus_server/domain/TestResult/dto/response/IncorrectProblemGetResponse.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.moplus.moplus_server.domain.TestResult.dto.response; - -import com.moplus.moplus_server.domain.TestResult.entity.IncorrectProblem; -import lombok.Builder; - -@Builder -public record IncorrectProblemGetResponse( - String problemNumber, - double correctRate -) { - - public static IncorrectProblemGetResponse from(IncorrectProblem incorrectProblem) { - return IncorrectProblemGetResponse.builder() - .problemNumber(incorrectProblem.getProblemNumber()) - .correctRate(incorrectProblem.getCorrectRate()) - .build(); - } - -} diff --git a/src/main/java/com/moplus/moplus_server/domain/TestResult/service/IncorrectProblemService.java b/src/main/java/com/moplus/moplus_server/domain/TestResult/service/IncorrectProblemService.java deleted file mode 100644 index 99c24a2..0000000 --- a/src/main/java/com/moplus/moplus_server/domain/TestResult/service/IncorrectProblemService.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.moplus.moplus_server.domain.TestResult.service; - -import com.moplus.moplus_server.domain.TestResult.dto.request.IncorrectProblemPostRequest; -import com.moplus.moplus_server.domain.TestResult.dto.response.IncorrectProblemGetResponse; -import com.moplus.moplus_server.domain.TestResult.entity.IncorrectProblem; -import com.moplus.moplus_server.domain.TestResult.entity.TestResult; -import com.moplus.moplus_server.domain.TestResult.repository.IncorrectProblemRepository; -import com.moplus.moplus_server.domain.practiceTest.domain.Problem; -import com.moplus.moplus_server.domain.practiceTest.service.client.ProblemService; -import java.util.ArrayList; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class IncorrectProblemService { - - private final IncorrectProblemRepository incorrectProblemRepository; - private final ProblemService problemService; - - public List<IncorrectProblem> saveIncorrectProblems( - List<IncorrectProblemPostRequest> requests, - Long practiceTestId, - TestResult testResult) { - List<Problem> problems = requests.stream() - .map(request -> problemService.getProblemByPracticeTestIdAndNumber(practiceTestId, request.problemNumber())) - .toList(); - - List<IncorrectProblem> incorrectProblems = new ArrayList<>(); - for (int i = 0; i < requests.size(); i++) { - Problem matchedProblem = problems.get(i); - IncorrectProblem tempIncorrectProblem = requests.get(i).toEntity(matchedProblem); - - tempIncorrectProblem.setTestResult(testResult); - tempIncorrectProblem.setPracticeTestId(practiceTestId); - tempIncorrectProblem.setCorrectRate(matchedProblem.getCorrectRate()); - IncorrectProblem save = incorrectProblemRepository.save(tempIncorrectProblem); - incorrectProblems.add(save); - } - return incorrectProblems; - } - - public List<IncorrectProblemGetResponse> getResponsesByTestResultId(Long testResultId) { - return incorrectProblemRepository.findAllByTestResultId(testResultId).stream() - .map(IncorrectProblemGetResponse::from) - .toList(); - } -} diff --git a/src/main/java/com/moplus/moplus_server/domain/auth/controller/AuthController.java b/src/main/java/com/moplus/moplus_server/domain/auth/controller/AuthController.java new file mode 100644 index 0000000..862b52a --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/auth/controller/AuthController.java @@ -0,0 +1,63 @@ +package com.moplus.moplus_server.domain.auth.controller; + +import com.moplus.moplus_server.domain.auth.dto.request.AdminLoginRequest; +import com.moplus.moplus_server.domain.auth.dto.response.AccessTokenResponse; +import com.moplus.moplus_server.domain.auth.dto.response.TokenResponse; +import com.moplus.moplus_server.domain.auth.service.AuthService; +import com.moplus.moplus_server.global.error.exception.ErrorCode; +import com.moplus.moplus_server.global.error.exception.NotFoundException; +import com.moplus.moplus_server.global.security.utils.CookieUtil; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import java.util.Arrays; +import lombok.RequiredArgsConstructor; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +public class AuthController implements AuthControllerDocs { + + private final AuthService authService; + private final CookieUtil cookieUtil; + + private static String validateRefreshTokenCookie(HttpServletRequest request) { + if (request.getCookies() == null) { + throw new NotFoundException(ErrorCode.BLANK_INPUT_VALUE); + } + Cookie[] cookies = request.getCookies(); + return Arrays.stream(cookies) + .filter(cookie -> "refreshToken".equals(cookie.getName())) + .map(Cookie::getValue) + .findFirst() + .orElseThrow(() -> new NotFoundException(ErrorCode.BLANK_INPUT_VALUE)); + } + + @Override + @PostMapping("/admin/login") + public ResponseEntity<AccessTokenResponse> adminLogin( + @Valid @RequestBody AdminLoginRequest request + ) { + // 실제 처리는 Security 필터에서 이루어지며, 이 메서드는 Swagger 명세용입니다. + return null; + } + + @Override + @GetMapping("/reissue") + public ResponseEntity<AccessTokenResponse> reissueToken(HttpServletRequest request, HttpServletResponse response) { + String refreshToken = validateRefreshTokenCookie(request); + + TokenResponse tokenResponse = authService.reissueToken(refreshToken); + + response.addCookie(cookieUtil.createCookie(tokenResponse.refreshToken())); + + return ResponseEntity.ok(new AccessTokenResponse(tokenResponse.accessToken())); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/auth/controller/AuthControllerDocs.java b/src/main/java/com/moplus/moplus_server/domain/auth/controller/AuthControllerDocs.java new file mode 100644 index 0000000..90a2610 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/auth/controller/AuthControllerDocs.java @@ -0,0 +1,81 @@ +package com.moplus.moplus_server.domain.auth.controller; + +import com.moplus.moplus_server.domain.auth.dto.request.AdminLoginRequest; +import com.moplus.moplus_server.domain.auth.dto.response.AccessTokenResponse; +import com.moplus.moplus_server.global.error.ErrorResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.headers.Header; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.ResponseEntity; + +@Tag(name = "인증", description = "인증 관련 API") +public interface AuthControllerDocs { + + @Operation( + summary = "어드민 로그인", + description = "이메일과 비밀번호로 로그인하여 액세스 토큰을 발급받고 리프레시 토큰을 쿠키에 설정합니다.", + responses = { + @ApiResponse( + responseCode = "200", + description = "로그인 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = AccessTokenResponse.class) + ), + headers = @Header( + name = "Set-Cookie", + description = "리프레시 토큰이 담긴 HTTP Only 쿠키", + schema = @Schema( + type = "string", + example = "refreshToken=xxx; Path=/; HttpOnly; Secure; SameSite=None" + ) + ) + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패 (잘못된 이메일 또는 비밀번호)", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ) + } + ) + ResponseEntity<AccessTokenResponse> adminLogin(AdminLoginRequest request); + + @Operation( + summary = "토큰 재발급", + description = "리프레시 토큰을 통해 새로운 액세스 토큰을 발급하고 새로운 리프레시 토큰을 쿠키에 설정합니다.", + responses = { + @ApiResponse( + responseCode = "200", + description = "토큰 재발급 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = AccessTokenResponse.class) + ), + headers = @Header( + name = "Set-Cookie", + description = "새로운 리프레시 토큰이 담긴 HTTP Only 쿠키", + schema = @Schema( + type = "string", + example = "refreshToken=xxx; Path=/; HttpOnly; Secure; SameSite=None" + ) + ) + ), + @ApiResponse( + responseCode = "401", + description = "유효하지 않은 리프레시 토큰", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + @ApiResponse( + responseCode = "404", + description = "리프레시 토큰 쿠키 없음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ) + } + ) + ResponseEntity<AccessTokenResponse> reissueToken(HttpServletRequest request, HttpServletResponse response); +} \ No newline at end of file diff --git a/src/main/java/com/moplus/moplus_server/domain/auth/dto/request/AdminLoginRequest.java b/src/main/java/com/moplus/moplus_server/domain/auth/dto/request/AdminLoginRequest.java new file mode 100644 index 0000000..e0c6c14 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/auth/dto/request/AdminLoginRequest.java @@ -0,0 +1,11 @@ +package com.moplus.moplus_server.domain.auth.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record AdminLoginRequest( + @NotNull(message = "이메일을 입력해주세요.") + String email, + @NotNull(message = "비밀번호를 입력해주세요.") + String password +) { +} diff --git a/src/main/java/com/moplus/moplus_server/domain/auth/dto/response/AccessTokenResponse.java b/src/main/java/com/moplus/moplus_server/domain/auth/dto/response/AccessTokenResponse.java new file mode 100644 index 0000000..25f5b8f --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/auth/dto/response/AccessTokenResponse.java @@ -0,0 +1,9 @@ +package com.moplus.moplus_server.domain.auth.dto.response; + +import jakarta.validation.constraints.NotNull; + +public record AccessTokenResponse( + @NotNull(message = "accessToken을 입력해주세요.") + String accessToken +) { +} \ No newline at end of file diff --git a/src/main/java/com/moplus/moplus_server/domain/auth/dto/response/TokenResponse.java b/src/main/java/com/moplus/moplus_server/domain/auth/dto/response/TokenResponse.java new file mode 100644 index 0000000..a2be86d --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/auth/dto/response/TokenResponse.java @@ -0,0 +1,7 @@ +package com.moplus.moplus_server.domain.auth.dto.response; + +public record TokenResponse( + String accessToken, + String refreshToken +) { +} diff --git a/src/main/java/com/moplus/moplus_server/domain/auth/service/AuthService.java b/src/main/java/com/moplus/moplus_server/domain/auth/service/AuthService.java new file mode 100644 index 0000000..b11bac9 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/auth/service/AuthService.java @@ -0,0 +1,65 @@ +package com.moplus.moplus_server.domain.auth.service; + +import com.moplus.moplus_server.domain.auth.dto.response.TokenResponse; +import com.moplus.moplus_server.domain.member.domain.Member; +import com.moplus.moplus_server.domain.member.service.MemberService; +import com.moplus.moplus_server.global.error.exception.ErrorCode; +import com.moplus.moplus_server.global.error.exception.InvalidValueException; +import com.moplus.moplus_server.global.security.exception.JwtInvalidException; +import com.moplus.moplus_server.global.security.utils.JwtUtil; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.security.SignatureException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final JwtUtil jwtUtil; + private final MemberService memberService; + + @Transactional + public TokenResponse reissueToken(String refreshToken) { + if (refreshToken == null) { + throw new InvalidValueException(ErrorCode.INVALID_INPUT_VALUE); + } + + Claims claims = getClaims(refreshToken); + final Member member = getMemberById(claims.getSubject()); + + // 새로운 액세스 토큰과 리프레시 토큰 생성 + String newAccessToken = jwtUtil.generateAccessToken(member); + String newRefreshToken = jwtUtil.generateRefreshToken(member); + + return new TokenResponse(newAccessToken, newRefreshToken); + } + + private Claims getClaims(String refreshToken) { + Claims claims; + try { + claims = jwtUtil.getRefreshTokenClaims(refreshToken); + } catch (ExpiredJwtException expiredJwtException) { + throw new JwtInvalidException(ErrorCode.EXPIRED_TOKEN.getMessage()); + } catch (SignatureException signatureException) { + throw new JwtInvalidException(ErrorCode.WRONG_TYPE_TOKEN.getMessage()); + } catch (MalformedJwtException malformedJwtException) { + throw new JwtInvalidException(ErrorCode.UNSUPPORTED_TOKEN.getMessage()); + } catch (IllegalArgumentException illegalArgumentException) { + throw new JwtInvalidException(ErrorCode.UNKNOWN_ERROR.getMessage()); + } + return claims; + } + + private Member getMemberById(String id) { + try { + return memberService.getMemberById(Long.parseLong(id)); + } catch (Exception e) { + throw new BadCredentialsException(ErrorCode.BAD_CREDENTIALS.getMessage()); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/moplus/moplus_server/domain/concept/controller/ConceptTagController.java b/src/main/java/com/moplus/moplus_server/domain/concept/controller/ConceptTagController.java new file mode 100644 index 0000000..9670ed7 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/concept/controller/ConceptTagController.java @@ -0,0 +1,31 @@ +package com.moplus.moplus_server.domain.concept.controller; + +import com.moplus.moplus_server.domain.concept.dto.response.ConceptTagResponse; +import com.moplus.moplus_server.domain.concept.repository.ConceptTagRepository; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +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; + +@Tag(name = "문항", description = "문항 관련 API") +@RestController +@RequestMapping("/api/v1/conceptTags") +@RequiredArgsConstructor +public class ConceptTagController { + + private final ConceptTagRepository conceptTagRepository; + + @GetMapping("") + @Operation(summary = "모든 개념 태그 리스트 조회") + public ResponseEntity<List<ConceptTagResponse>> getConceptTags( + ) { + List<ConceptTagResponse> responses = conceptTagRepository.findAll().stream() + .map(ConceptTagResponse::of) + .toList(); + return ResponseEntity.ok(responses); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/concept/domain/ConceptTag.java b/src/main/java/com/moplus/moplus_server/domain/concept/domain/ConceptTag.java new file mode 100644 index 0000000..90d4c83 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/concept/domain/ConceptTag.java @@ -0,0 +1,28 @@ +package com.moplus.moplus_server.domain.concept.domain; + +import com.moplus.moplus_server.global.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ConceptTag extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "concept_tag_id") + Long id; + + String name; + + public ConceptTag(String name) { + this.name = name; + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/concept/dto/response/ConceptTagResponse.java b/src/main/java/com/moplus/moplus_server/domain/concept/dto/response/ConceptTagResponse.java new file mode 100644 index 0000000..0f5e5cd --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/concept/dto/response/ConceptTagResponse.java @@ -0,0 +1,18 @@ +package com.moplus.moplus_server.domain.concept.dto.response; + +import com.moplus.moplus_server.domain.concept.domain.ConceptTag; +import jakarta.validation.constraints.NotNull; + +public record ConceptTagResponse( + @NotNull(message = "개념 태그 ID는 필수입니다") + Long id, + @NotNull(message = "개념 태그 이름은 필수입니다") + String name +) { + public static ConceptTagResponse of(ConceptTag entity) { + return new ConceptTagResponse( + entity.getId(), + entity.getName() + ); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/concept/repository/ConceptTagRepository.java b/src/main/java/com/moplus/moplus_server/domain/concept/repository/ConceptTagRepository.java new file mode 100644 index 0000000..66f71a3 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/concept/repository/ConceptTagRepository.java @@ -0,0 +1,30 @@ +package com.moplus.moplus_server.domain.concept.repository; + +import com.moplus.moplus_server.domain.concept.domain.ConceptTag; +import com.moplus.moplus_server.global.error.exception.ErrorCode; +import com.moplus.moplus_server.global.error.exception.NotFoundException; +import java.util.List; +import java.util.Set; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ConceptTagRepository extends JpaRepository<ConceptTag, Long> { + + default void existsByIdElseThrow(Set<Long> ids) { + List<Long> foundIds = findAllById(ids).stream() + .map(ConceptTag::getId) // 엔티티의 ID 추출 + .toList(); + + if (ids.size() != foundIds.size()) { + throw new NotFoundException(ErrorCode.CONCEPT_TAG_NOT_FOUND_IN_LIST); + } + } + + + default List<ConceptTag> findAllByIdsElseThrow(Set<Long> ids) { + List<ConceptTag> conceptTags = findAllById(ids); + if (conceptTags.size() != ids.size()) { + throw new NotFoundException(ErrorCode.CONCEPT_TAG_NOT_FOUND_IN_LIST); + } + return conceptTags; + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/member/controller/MemberController.java b/src/main/java/com/moplus/moplus_server/domain/member/controller/MemberController.java new file mode 100644 index 0000000..43a0cb2 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/member/controller/MemberController.java @@ -0,0 +1,27 @@ +package com.moplus.moplus_server.domain.member.controller; + +import com.moplus.moplus_server.domain.member.domain.Member; +import com.moplus.moplus_server.domain.member.dto.response.MemberGetResponse; +import com.moplus.moplus_server.global.annotation.AuthUser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +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; + +@Tag(name = "회원", description = "회원 관련 API") +@RestController +@RequestMapping("/api/v1/member") +@RequiredArgsConstructor +public class MemberController { + + @GetMapping("me") + @Operation(summary = "내 정보 조회", description = "jwt accessToken을 통해 내 정보를 조회합니다.") + public ResponseEntity<MemberGetResponse> getMyInfo( + @AuthUser Member member + ) { + return ResponseEntity.ok(MemberGetResponse.of(member)); + } +} \ No newline at end of file diff --git a/src/main/java/com/moplus/moplus_server/domain/member/domain/Member.java b/src/main/java/com/moplus/moplus_server/domain/member/domain/Member.java new file mode 100644 index 0000000..fb000bd --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/member/domain/Member.java @@ -0,0 +1,44 @@ +package com.moplus.moplus_server.domain.member.domain; + +import com.moplus.moplus_server.global.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Member extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_id") + private Long id; + + private String name; + private String email; + private String password; + + @Enumerated(EnumType.STRING) + private MemberRole role; + + @Builder + public Member(String name, String email, String password, MemberRole role) { + this.name = name; + this.email = email; + this.password = password; + this.role = role; + } + + public boolean isMatchingPassword(String password) { + return this.password.equals(password); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/member/domain/MemberRole.java b/src/main/java/com/moplus/moplus_server/domain/member/domain/MemberRole.java new file mode 100644 index 0000000..3f38166 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/member/domain/MemberRole.java @@ -0,0 +1,21 @@ +package com.moplus.moplus_server.domain.member.domain; + +import java.util.Arrays; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum MemberRole { + USER("ROLE_USER"), + ADMIN("ROLE_ADMIN"); + + private final String value; + + public static MemberRole findByKey(String value) { + return Arrays.stream(MemberRole.values()) + .filter(role -> role.value.equals(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("No role with key: " + value)); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/member/dto/response/MemberGetResponse.java b/src/main/java/com/moplus/moplus_server/domain/member/dto/response/MemberGetResponse.java new file mode 100644 index 0000000..0585dac --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/member/dto/response/MemberGetResponse.java @@ -0,0 +1,18 @@ +package com.moplus.moplus_server.domain.member.dto.response; + + +import com.moplus.moplus_server.domain.member.domain.Member; + +public record MemberGetResponse( + Long id, + String name, + String email +) { + public static MemberGetResponse of(Member member) { + return new MemberGetResponse( + member.getId(), + member.getName(), + member.getEmail() + ); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/member/repository/MemberRepository.java b/src/main/java/com/moplus/moplus_server/domain/member/repository/MemberRepository.java new file mode 100644 index 0000000..9eb0168 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/member/repository/MemberRepository.java @@ -0,0 +1,15 @@ +package com.moplus.moplus_server.domain.member.repository; + +import com.moplus.moplus_server.domain.member.domain.Member; +import com.moplus.moplus_server.global.error.exception.ErrorCode; +import com.moplus.moplus_server.global.error.exception.NotFoundException; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberRepository extends JpaRepository<Member, Long> { + Optional<Member> findByEmail(String email); + + default Member findByEmailOrThrow(String email) { + return findByEmail(email).orElseThrow(() -> new NotFoundException(ErrorCode.MEMBER_NOT_FOUND)); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/member/service/MemberService.java b/src/main/java/com/moplus/moplus_server/domain/member/service/MemberService.java new file mode 100644 index 0000000..0940f76 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/member/service/MemberService.java @@ -0,0 +1,24 @@ +package com.moplus.moplus_server.domain.member.service; + +import com.moplus.moplus_server.domain.member.domain.Member; +import com.moplus.moplus_server.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class MemberService { + + private final MemberRepository memberRepository; + + @Transactional(readOnly = true) + public Member getMemberByEmail(String email) { + return memberRepository.findByEmailOrThrow(email); + } + + @Transactional(readOnly = true) + public Member getMemberById(Long id) { + return memberRepository.findById(id).orElseThrow(); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/practiceTest/domain/Subject.java b/src/main/java/com/moplus/moplus_server/domain/practiceTest/domain/Subject.java deleted file mode 100644 index ff6bdfa..0000000 --- a/src/main/java/com/moplus/moplus_server/domain/practiceTest/domain/Subject.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.moplus.moplus_server.domain.practiceTest.domain; - - -import java.util.Arrays; -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -public enum Subject { - -// 화법과작문("화법과작문", 45, 100), -// 언어와매체("언어와매체", 45, 100), - 미적분("미적분", 30, 100), - 확률과통계("확률과통계",30, 100), - 기하("기하",30, 100); -// 영어("영어",45, 100), -// 물리I("물리I",20, 50), -// 화학I("화학I",20, 50), -// 생명과학I("생명과학I",20, 50), -// 지구과학I("지구과학I",20, 50), -// 물리II("물리II",20, 50), -// 화학II("화학II",20, 50), -// 생명과학II("생명과학II",20, 50), -// 지구과학II("지구과학II",20, 50), -// 한국지리("한국지리",20, 50), -// 세계지리("세계지리",20, 50), -// 동아시아사("동아시아사",20, 50), -// 생활과윤리("생활과윤리",20, 50), -// 윤리와사상("윤리와사상",20, 50), -// 사회문화("사회문화",20, 50), -// 정치와법("정치와법",20, 50), -// 경제("경제",20, 50); - - private final String value; - private final int problemCount; - private final int perfectScore; - - public static Subject fromValue(String value) { - return Arrays.stream(Subject.values()) - .filter(subject -> subject.value.equals(value)) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("해당 값에 맞는 Subject가 없습니다: " + value)); - } -} diff --git a/src/main/java/com/moplus/moplus_server/domain/practiceTest/dto/admin/request/ProblemImageRequest.java b/src/main/java/com/moplus/moplus_server/domain/practiceTest/dto/admin/request/ProblemImageRequest.java deleted file mode 100644 index ef572e7..0000000 --- a/src/main/java/com/moplus/moplus_server/domain/practiceTest/dto/admin/request/ProblemImageRequest.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.moplus.moplus_server.domain.practiceTest.dto.admin.request; - -import com.moplus.moplus_server.domain.practiceTest.domain.Problem; -import lombok.Builder; - -@Builder -public record ProblemImageRequest( - Long problemId, - String problemNumber, - String imageUrl -) { - - public static ProblemImageRequest of(Problem problem) { - return ProblemImageRequest.builder() - .problemId(problem.getId()) - .problemNumber(problem.getProblemNumber()) - .imageUrl(problem.getImage() != null ? problem.getImage().getImageUrl() : null) - .build(); - } -} diff --git a/src/main/java/com/moplus/moplus_server/domain/practiceTest/dto/admin/request/ProblemPostRequest.java b/src/main/java/com/moplus/moplus_server/domain/practiceTest/dto/admin/request/ProblemPostRequest.java deleted file mode 100644 index 62026da..0000000 --- a/src/main/java/com/moplus/moplus_server/domain/practiceTest/dto/admin/request/ProblemPostRequest.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.moplus.moplus_server.domain.practiceTest.dto.admin.request; - -public record ProblemPostRequest( - -) { - -} diff --git a/src/main/java/com/moplus/moplus_server/domain/practiceTest/dto/admin/response/PracticeTestAdminResponse.java b/src/main/java/com/moplus/moplus_server/domain/practiceTest/dto/admin/response/PracticeTestAdminResponse.java deleted file mode 100644 index d464fa1..0000000 --- a/src/main/java/com/moplus/moplus_server/domain/practiceTest/dto/admin/response/PracticeTestAdminResponse.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.moplus.moplus_server.domain.practiceTest.dto.admin.response; - -import com.moplus.moplus_server.domain.practiceTest.domain.PracticeTest; -import lombok.Builder; - -@Builder -public record PracticeTestAdminResponse( - Long id, - String name, - String round, - String provider, - String subject -) { - public static PracticeTestAdminResponse from(PracticeTest practiceTest) { - return PracticeTestAdminResponse.builder() - .id(practiceTest.getId()) - .name(practiceTest.getName()) - .provider(practiceTest.getProvider()) - .round(practiceTest.getRound()) - .subject(practiceTest.getSubject().getValue()) - .build(); - } -} diff --git a/src/main/java/com/moplus/moplus_server/domain/practiceTest/dto/client/response/PracticeTestGetResponse.java b/src/main/java/com/moplus/moplus_server/domain/practiceTest/dto/client/response/PracticeTestGetResponse.java deleted file mode 100644 index 1dc3d92..0000000 --- a/src/main/java/com/moplus/moplus_server/domain/practiceTest/dto/client/response/PracticeTestGetResponse.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.moplus.moplus_server.domain.practiceTest.dto.client.response; - -import com.moplus.moplus_server.domain.practiceTest.domain.PracticeTest; -import lombok.Builder; - -@Builder -public record PracticeTestGetResponse( - Long id, - String name, - String round, - String provider, - String subject, - long viewCount, - int totalSolvesCount -) { - public static PracticeTestGetResponse from(PracticeTest practiceTest) { - return PracticeTestGetResponse.builder() - .id(practiceTest.getId()) - .name(practiceTest.getName()) - .provider(practiceTest.getProvider()) - .round(practiceTest.getRound()) - .subject(practiceTest.getSubject().getValue()) - .viewCount(practiceTest.getViewCount()) - .totalSolvesCount(practiceTest.getSolvesCount()) - .build(); - } -} diff --git a/src/main/java/com/moplus/moplus_server/domain/practiceTest/dto/client/response/ProblemGetResponse.java b/src/main/java/com/moplus/moplus_server/domain/practiceTest/dto/client/response/ProblemGetResponse.java deleted file mode 100644 index 3803c3f..0000000 --- a/src/main/java/com/moplus/moplus_server/domain/practiceTest/dto/client/response/ProblemGetResponse.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.moplus.moplus_server.domain.practiceTest.dto.client.response; - -import com.moplus.moplus_server.domain.practiceTest.domain.Problem; -import lombok.Builder; - -@Builder -public record ProblemGetResponse( - Long id, - String problemNumber, - String answerFormat, - String answer, - int point, - double correctRate -) { - - public static ProblemGetResponse from(Problem problem) { - return ProblemGetResponse.builder() - .id(problem.getId()) - .answer(problem.getAnswer()) - .problemNumber(problem.getProblemNumber()) - .answerFormat(problem.getAnswerFormat().getValue()) - .point(problem.getPoint()) - .correctRate(problem.getCorrectRate()) - .build(); - } -} diff --git a/src/main/java/com/moplus/moplus_server/domain/practiceTest/repository/ProblemImageRepository.java b/src/main/java/com/moplus/moplus_server/domain/practiceTest/repository/ProblemImageRepository.java deleted file mode 100644 index 353087b..0000000 --- a/src/main/java/com/moplus/moplus_server/domain/practiceTest/repository/ProblemImageRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.moplus.moplus_server.domain.practiceTest.repository; - -import com.moplus.moplus_server.domain.practiceTest.domain.ProblemImage; -import java.util.Optional; -import javax.swing.JPanel; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface ProblemImageRepository extends JpaRepository<ProblemImage, Long> { - - Optional<ProblemImage> findByProblemId(Long problemId); -} diff --git a/src/main/java/com/moplus/moplus_server/domain/practiceTest/repository/ProblemRepository.java b/src/main/java/com/moplus/moplus_server/domain/practiceTest/repository/ProblemRepository.java deleted file mode 100644 index cc79a64..0000000 --- a/src/main/java/com/moplus/moplus_server/domain/practiceTest/repository/ProblemRepository.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.moplus.moplus_server.domain.practiceTest.repository; - -import com.moplus.moplus_server.domain.practiceTest.domain.PracticeTest; -import com.moplus.moplus_server.domain.practiceTest.domain.Problem; -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.persistence.LockModeType; -import java.util.List; -import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Lock; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -public interface ProblemRepository extends JpaRepository<Problem, Long> { - - List<Problem> findAllByPracticeTestId(Long id); - - void deleteAllByPracticeTestId(Long id); - - Optional<Problem> findByProblemNumberAndPracticeTestId(String problemNumber, Long practiceTest_id); - - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("SELECT p FROM Problem p WHERE p.problemNumber = :problem_number AND p.practiceTest.id = :practice_test_id") - Optional<Problem> findByProblemNumberAndPracticeTestIdWithPessimisticLock(@Param("problem_number") String problemNumber,@Param("practice_test_id") Long practiceTest_id); -} diff --git a/src/main/java/com/moplus/moplus_server/domain/practiceTest/service/client/ProblemService.java b/src/main/java/com/moplus/moplus_server/domain/practiceTest/service/client/ProblemService.java deleted file mode 100644 index 42377e0..0000000 --- a/src/main/java/com/moplus/moplus_server/domain/practiceTest/service/client/ProblemService.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.moplus.moplus_server.domain.practiceTest.service.client; - -import com.moplus.moplus_server.domain.practiceTest.dto.admin.request.ProblemCreateRequest; -import com.moplus.moplus_server.domain.practiceTest.dto.client.response.ProblemGetResponse; -import com.moplus.moplus_server.domain.practiceTest.domain.PracticeTest; -import com.moplus.moplus_server.domain.practiceTest.domain.Problem; -import com.moplus.moplus_server.domain.practiceTest.repository.ProblemRepository; -import com.moplus.moplus_server.global.error.exception.ErrorCode; -import com.moplus.moplus_server.global.error.exception.NotFoundException; -import jakarta.servlet.http.HttpServletRequest; -import java.util.ArrayList; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -public class ProblemService { - - private final ProblemRepository problemRepository; - - @Transactional - public void saveProblems(PracticeTest practiceTest, HttpServletRequest request) { - - List<ProblemCreateRequest> problems = new ArrayList<>(); - - for (int i = 1; i <= practiceTest.getSubject().getProblemCount(); i++) { - String problemNumber = String.valueOf(i); - String answerFormat = request.getParameter("answerFormat_" + i); - String answer = request.getParameter("answer_" + i); - int point = Integer.parseInt(request.getParameter("point_" + i)); - double correctRate = Double.parseDouble(request.getParameter("correctRate_" + i)); - - ProblemCreateRequest problem = new ProblemCreateRequest(problemNumber, answerFormat, answer, point, - correctRate); - problems.add(problem); - } - List<Problem> problemsEntities = problems.stream() - .map(problem -> problem.toEntity(practiceTest)) - .toList(); - problemsEntities - .forEach(Problem::calculateProblemRating); - problemRepository.saveAll(problemsEntities); - } - - @Transactional - public void updateProblems(PracticeTest practiceTest, HttpServletRequest request) { - List<Problem> problems = problemRepository.findAllByPracticeTestId(practiceTest.getId()); - - for (int i = 1; i <= practiceTest.getSubject().getProblemCount(); i++) { - Problem problem = problems.get(i - 1); - problem.updateAnswer(request.getParameter("answer_" + i)); - problem.updatePoint(Integer.parseInt(request.getParameter("point_" + i))); - problem.updateCorrectRate(Double.parseDouble(request.getParameter("correctRate_" + i))); - - problem.calculateProblemRating(); - problemRepository.save(problem); - } - } - - - public Problem getProblemByPracticeTestIdAndNumber(Long practiceId, String problemNumber) { - return problemRepository.findByProblemNumberAndPracticeTestId(problemNumber, practiceId) - .orElseThrow(() -> new NotFoundException(ErrorCode.PROBLEM_NOT_FOUND)); - } - - @Transactional - public Problem updateCorrectRate(Long practiceTestId, String problemNumber, double correctRate) { - Problem problem = problemRepository.findByProblemNumberAndPracticeTestIdWithPessimisticLock(problemNumber, - practiceTestId) - .orElseThrow(() -> new NotFoundException(ErrorCode.PROBLEM_NOT_FOUND)); - problem.getPracticeTest(); - problem.updateCorrectRate(correctRate); - return problemRepository.save(problem); - } - - public List<ProblemGetResponse> getProblemsByTestId(Long testId) { - return problemRepository.findAllByPracticeTestId(testId).stream() - .map(ProblemGetResponse::from) - .toList(); - } -} diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/controller/ImageUploadController.java b/src/main/java/com/moplus/moplus_server/domain/problem/controller/ImageUploadController.java new file mode 100644 index 0000000..9afcfc1 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/controller/ImageUploadController.java @@ -0,0 +1,40 @@ +package com.moplus.moplus_server.domain.problem.controller; + +import com.moplus.moplus_server.domain.problem.domain.problem.ProblemImageType; +import com.moplus.moplus_server.domain.problem.dto.response.PresignedUrlResponse; +import com.moplus.moplus_server.domain.problem.service.ImageUploadService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "이미지 업로드 API", description = "이미지 업로드 관련 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/images") +public class ImageUploadController { + + private final ImageUploadService imageUploadService; + + @Operation(summary = "이미지 업로드를 위한 presigned URL 발급") + @GetMapping("/problem/{problemId}/presigned-url") + public ResponseEntity<PresignedUrlResponse> getProblemImagePresignedUrl( + @PathVariable("problemId") Long problemId, + @RequestParam(value = "image-type") ProblemImageType imageType) { + String presignedUrl = imageUploadService.generateProblemImagePresignedUrl(problemId, imageType); + return ResponseEntity.ok(PresignedUrlResponse.of(presignedUrl)); + } + + @Operation(summary = "이미지 업로드 완료 후 URL 조회") + @GetMapping("/{fileName}") + public ResponseEntity<String> getImageUrl( + @PathVariable("fileName") String fileName) { + String imageUrl = imageUploadService.getImageUrl(fileName); + return ResponseEntity.ok(imageUrl); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/controller/PracticeTestTagController.java b/src/main/java/com/moplus/moplus_server/domain/problem/controller/PracticeTestTagController.java new file mode 100644 index 0000000..ba0703c --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/controller/PracticeTestTagController.java @@ -0,0 +1,31 @@ +package com.moplus.moplus_server.domain.problem.controller; + +import com.moplus.moplus_server.domain.problem.dto.response.PracticeTestTagResponse; +import com.moplus.moplus_server.domain.problem.repository.PracticeTestTagRepository; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +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; + + +@Tag(name = "문항", description = "문항 관련 API") +@RestController +@RequestMapping("/api/v1/practiceTestTags") +@RequiredArgsConstructor +public class PracticeTestTagController { + + private final PracticeTestTagRepository practiceTestTagRepository; + + @GetMapping("") + @Operation(summary = "모의고사 목록 조회") + public ResponseEntity<List<PracticeTestTagResponse>> getPracticeTestTags() { + List<PracticeTestTagResponse> responses = practiceTestTagRepository.findAll().stream() + .map(PracticeTestTagResponse::of) + .toList(); + return ResponseEntity.ok(responses); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/controller/ProblemController.java b/src/main/java/com/moplus/moplus_server/domain/problem/controller/ProblemController.java new file mode 100644 index 0000000..9db45a7 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/controller/ProblemController.java @@ -0,0 +1,90 @@ +package com.moplus.moplus_server.domain.problem.controller; + +import com.moplus.moplus_server.domain.problem.dto.request.ProblemPostRequest; +import com.moplus.moplus_server.domain.problem.dto.request.ProblemUpdateRequest; +import com.moplus.moplus_server.domain.problem.dto.response.ProblemGetResponse; +import com.moplus.moplus_server.domain.problem.dto.response.ProblemPostResponse; +import com.moplus.moplus_server.domain.problem.service.ChildProblemService; +import com.moplus.moplus_server.domain.problem.service.ProblemDeleteService; +import com.moplus.moplus_server.domain.problem.service.ProblemGetService; +import com.moplus.moplus_server.domain.problem.service.ProblemSaveService; +import com.moplus.moplus_server.domain.problem.service.ProblemUpdateService; +import com.moplus.moplus_server.global.response.IdResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +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.RestController; + +@Tag(name = "문항", description = "문항 관련 API") +@RestController +@RequestMapping("/api/v1/problems") +@RequiredArgsConstructor +public class ProblemController { + + private final ProblemSaveService problemSaveService; + private final ProblemUpdateService problemUpdateService; + private final ProblemGetService problemGetService; + private final ProblemDeleteService problemDeleteService; + private final ChildProblemService childProblemService; + + @GetMapping("/{id}") + @Operation(summary = "문항 조회", description = "문항를 조회합니다.") + public ResponseEntity<ProblemGetResponse> getProblem( + @PathVariable("id") Long id + ) { + return ResponseEntity.ok(problemGetService.getProblem(id)); + } + + @PostMapping("") + @Operation(summary = "문항 생성", description = "문제를 생성합니다. 기출/변형 문제는 모든 값이 필수이며 창작 문제는 문항 타입만 필수 입니다.") + public ResponseEntity<ProblemPostResponse> createProblem( + @Valid @RequestBody ProblemPostRequest request + ) { + return ResponseEntity.ok(problemSaveService.createProblem(request)); + } + + @PutMapping("/{id}") + @Operation(summary = "문항 업데이트", description = "문제를 업데이트합니다. 새끼문항은 들어온 list의 순서로 저장됩니다.") + public ResponseEntity<ProblemGetResponse> updateProblem( + @PathVariable("id") Long id, + @RequestBody ProblemUpdateRequest request + ) { + return ResponseEntity.ok(problemUpdateService.updateProblem(id, request)); + } + + @DeleteMapping("/{id}") + @Operation(summary = "문항 삭제") + public ResponseEntity<Void> updateProblem( + @PathVariable("id") Long id + ) { + problemDeleteService.deleteProblem(id); + return ResponseEntity.ok().body(null); + } + + @PostMapping("/{problemId}/child-problems") + @Operation(summary = "새끼문항 추가", description = "추가되는 새끼 문항의 id를 반환합니다. 컨펌 이후에는 새끼 문항 추가가 불가능합니다.") + public ResponseEntity<IdResponse> createChildProblem( + @PathVariable("problemId") Long problemId + ) { + return ResponseEntity.ok(new IdResponse(childProblemService.createChildProblem(problemId))); + } + + @DeleteMapping("/{problemId}/child-problems/{childProblemId}") + @Operation(summary = "새끼 문항 삭제", description = "컨펌 이후에는 새끼 문항 삭제가 불가능합니다.") + public ResponseEntity<Void> deleteChildProblem( + @PathVariable("problemId") Long problemId, + @PathVariable("childProblemId") Long childProblemId + ) { + childProblemService.deleteChildProblem(problemId, childProblemId); + return ResponseEntity.ok().body(null); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/controller/ProblemSearchController.java b/src/main/java/com/moplus/moplus_server/domain/problem/controller/ProblemSearchController.java new file mode 100644 index 0000000..1ddf542 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/controller/ProblemSearchController.java @@ -0,0 +1,37 @@ +package com.moplus.moplus_server.domain.problem.controller; + +import com.moplus.moplus_server.domain.problem.dto.response.ProblemSearchGetResponse; +import com.moplus.moplus_server.domain.problem.repository.ProblemSearchRepositoryCustom; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "문항", description = "문항 관련 API") +@RestController +@RequestMapping("/api/v1/problems") +@RequiredArgsConstructor +public class ProblemSearchController { + + private final ProblemSearchRepositoryCustom problemSearchRepository; + + @GetMapping("/search") + @Operation( + summary = "문제 검색", + description = "문항 ID, 문제명, 개념 태그리스트로 문제를 검색합니다. 개념 태그리스트는 OR 조건으로 검색하며 값이 없으면 쿼리파라미터에서 빼주세요" + ) + public ResponseEntity<List<ProblemSearchGetResponse>> search( + @RequestParam(value = "problemCustomId", required = false) String problemCustomId, + @RequestParam(value = "title", required = false) String title, + @RequestParam(value = "conceptTagIds", required = false) List<Long> conceptTagIds + ) { + List<ProblemSearchGetResponse> problems = problemSearchRepository.search(problemCustomId, title, + conceptTagIds); + return ResponseEntity.ok(problems); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/domain/Answer.java b/src/main/java/com/moplus/moplus_server/domain/problem/domain/Answer.java new file mode 100644 index 0000000..8f2d5aa --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/domain/Answer.java @@ -0,0 +1,35 @@ +package com.moplus.moplus_server.domain.problem.domain; + +import com.moplus.moplus_server.domain.problem.domain.problem.AnswerType; +import com.moplus.moplus_server.global.error.exception.ErrorCode; +import com.moplus.moplus_server.global.error.exception.InvalidValueException; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Answer { + + @Column(name = "answer") + private String value; + + public Answer(String value, AnswerType answerType) { + if (value == null) { + return; + } + validateByType(value, answerType); + this.value = value; + } + + private void validateByType(String answer, AnswerType answerType) { + if (answerType == AnswerType.MULTIPLE_CHOICE) { + if (!answer.matches("^[1-5]*$")) { + throw new InvalidValueException(ErrorCode.INVALID_MULTIPLE_CHOICE_ANSWER); + } + } + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/domain/childProblem/ChildProblem.java b/src/main/java/com/moplus/moplus_server/domain/problem/domain/childProblem/ChildProblem.java new file mode 100644 index 0000000..08c83c5 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/domain/childProblem/ChildProblem.java @@ -0,0 +1,84 @@ +package com.moplus.moplus_server.domain.problem.domain.childProblem; + +import com.moplus.moplus_server.domain.problem.domain.Answer; +import com.moplus.moplus_server.domain.problem.domain.problem.AnswerType; +import com.moplus.moplus_server.global.common.BaseEntity; +import com.moplus.moplus_server.global.error.exception.ErrorCode; +import com.moplus.moplus_server.global.error.exception.InvalidValueException; +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import java.util.Set; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ChildProblem extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "child_problem_id") + Long id; + @ElementCollection + @CollectionTable(name = "child_problem_concept", joinColumns = @JoinColumn(name = "child_problem_id")) + @Column(name = "concept_tag_id") + Set<Long> conceptTagIds; + private String imageUrl; + @Embedded + private Answer answer; + @Enumerated(EnumType.STRING) + private AnswerType answerType; + + @Builder + public ChildProblem(Long id, String imageUrl, AnswerType answerType, String answer, Set<Long> conceptTagIds) { + this.id = id; + validateAnswerByType(answer, answerType); + this.imageUrl = imageUrl; + this.answerType = answerType; + this.answer = new Answer(answer, answerType); + this.conceptTagIds = conceptTagIds; + } + + public static ChildProblem createEmptyChildProblem() { + return ChildProblem.builder() + .imageUrl("") + .answerType(AnswerType.SHORT_ANSWER) + .answer("") + .conceptTagIds(Set.of()) + .build(); + } + + public void validateAnswerByType(String answer, AnswerType answerType) { + if (answerType == AnswerType.MULTIPLE_CHOICE) { + if (!answer.matches("^[1-5]*$")) { + throw new InvalidValueException(ErrorCode.INVALID_MULTIPLE_CHOICE_ANSWER); + } + } + } + + public void update(ChildProblem input) { + if (!this.id.equals(input.id)) { + throw new InvalidValueException(ErrorCode.INVALID_CHILD_PROBLEM_SEQUENCE); + } + this.imageUrl = input.imageUrl; + this.answerType = input.answerType; + this.answer = input.answer; + this.conceptTagIds = input.conceptTagIds; + } + + public String getAnswer() { + return answer.getValue(); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/domain/practiceTest/PracticeTestTag.java b/src/main/java/com/moplus/moplus_server/domain/problem/domain/practiceTest/PracticeTestTag.java new file mode 100644 index 0000000..6f57f26 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/domain/practiceTest/PracticeTestTag.java @@ -0,0 +1,42 @@ +package com.moplus.moplus_server.domain.problem.domain.practiceTest; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "practice_test_tag") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PracticeTestTag { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "practice_test_tag_id") + private Long id; + + private String name; + @Column(name = "test_year") + private int year; + @Column(name = "test_month") + private int month; + @Enumerated(value = EnumType.STRING) + private Subject subject; + private String area; + + public PracticeTestTag(String name, int year, int month, Subject subject) { + this.name = name; + this.year = year; + this.month = month; + this.subject = subject; + this.area = "수학"; + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/domain/practiceTest/Subject.java b/src/main/java/com/moplus/moplus_server/domain/problem/domain/practiceTest/Subject.java new file mode 100644 index 0000000..75678f1 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/domain/practiceTest/Subject.java @@ -0,0 +1,30 @@ +package com.moplus.moplus_server.domain.problem.domain.practiceTest; + + +import java.util.Arrays; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum Subject { + + 고1("고1", 30, 100, 1), + 고2("고2", 30, 100, 2), + 미적분("미적분", 30, 100, 3), + 기하("기하", 30, 100, 4), + 확률과통계("확률과통계", 30, 100, 5), + ; + + private final String value; + private final int problemCount; + private final int perfectScore; + private final int code; + + public static Subject fromValue(String value) { + return Arrays.stream(Subject.values()) + .filter(subject -> subject.value.equals(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("해당 값에 맞는 Subject가 없습니다: " + value)); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/domain/problem/AnswerType.java b/src/main/java/com/moplus/moplus_server/domain/problem/domain/problem/AnswerType.java new file mode 100644 index 0000000..ac2760e --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/domain/problem/AnswerType.java @@ -0,0 +1,12 @@ +package com.moplus.moplus_server.domain.problem.domain.problem; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum AnswerType { + MULTIPLE_CHOICE("객관식"), + SHORT_ANSWER("주관식"); + + + private final String name; +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/domain/problem/Difficulty.java b/src/main/java/com/moplus/moplus_server/domain/problem/domain/problem/Difficulty.java new file mode 100644 index 0000000..68a0949 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/domain/problem/Difficulty.java @@ -0,0 +1,32 @@ +package com.moplus.moplus_server.domain.problem.domain.problem; + +import com.moplus.moplus_server.global.error.exception.ErrorCode; +import com.moplus.moplus_server.global.error.exception.InvalidValueException; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Difficulty { + + @Column(name = "difficulty") + private Integer difficulty; + + public Difficulty(Integer difficulty) { + validate(difficulty); + this.difficulty = difficulty; + } + + private void validate(Integer difficulty) { + if (difficulty == null) { + return; + } + if (difficulty < 1 || difficulty > 10) { + throw new InvalidValueException(ErrorCode.INVALID_DIFFICULTY); + } + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/domain/problem/Problem.java b/src/main/java/com/moplus/moplus_server/domain/problem/domain/problem/Problem.java new file mode 100644 index 0000000..ee6a5bc --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/domain/problem/Problem.java @@ -0,0 +1,197 @@ +package com.moplus.moplus_server.domain.problem.domain.problem; + +import com.moplus.moplus_server.domain.problem.domain.Answer; +import com.moplus.moplus_server.domain.problem.domain.childProblem.ChildProblem; +import com.moplus.moplus_server.domain.problem.domain.practiceTest.PracticeTestTag; +import com.moplus.moplus_server.domain.problem.repository.converter.StringListConverter; +import com.moplus.moplus_server.global.common.BaseEntity; +import com.moplus.moplus_server.global.error.exception.ErrorCode; +import com.moplus.moplus_server.global.error.exception.InvalidValueException; +import jakarta.persistence.CascadeType; +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OrderColumn; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Problem extends BaseEntity { + + @Embedded + ProblemCustomId problemCustomId; + Long practiceTestId; + int number; + @Enumerated(EnumType.STRING) + ProblemType problemType; + @Embedded + Title title; + @Embedded + Answer answer; + @Embedded + Difficulty difficulty; + + String memo; + String mainProblemImageUrl; + String mainAnalysisImageUrl; + String mainHandwritingExplanationImageUrl; + String readingTipImageUrl; + String seniorTipImageUrl; + + @Convert(converter = StringListConverter.class) + @Column(columnDefinition = "TEXT") + List<String> prescriptionImageUrls; + @ElementCollection + @CollectionTable(name = "problem_concept", joinColumns = @JoinColumn(name = "problem_id")) + @Column(name = "concept_tag_id") + Set<Long> conceptTagIds; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "problem_id") + private Long id; + + @Enumerated(EnumType.STRING) + private AnswerType answerType; + + private boolean isConfirmed; + + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + @JoinColumn(name = "problem_id") + @OrderColumn(name = "sequence") + private List<ChildProblem> childProblems = new ArrayList<>(); + + @Embedded + private RecommendedTime recommendedTime; + + @Builder + public Problem(List<ChildProblem> childProblems, boolean isConfirmed, AnswerType answerType, + Set<Long> conceptTagIds, Integer difficulty, String mainHandwritingExplanationImageUrl, + List<String> prescriptionImageUrls, String seniorTipImageUrl, String readingTipImageUrl, + String mainAnalysisImageUrl, String mainProblemImageUrl, String memo, String answer, String title, + ProblemType problemType, int number, PracticeTestTag practiceTestTag, + ProblemCustomId problemCustomId, Integer recommendedMinute, Integer recommendedSecond) { + this.childProblems = childProblems; + this.isConfirmed = isConfirmed; + this.answerType = answerType; + this.conceptTagIds = conceptTagIds; + this.mainHandwritingExplanationImageUrl = mainHandwritingExplanationImageUrl; + this.prescriptionImageUrls = prescriptionImageUrls; + this.seniorTipImageUrl = seniorTipImageUrl; + this.readingTipImageUrl = readingTipImageUrl; + this.mainAnalysisImageUrl = mainAnalysisImageUrl; + this.mainProblemImageUrl = mainProblemImageUrl; + this.difficulty = new Difficulty(difficulty); + this.memo = memo; + this.answer = new Answer(answer, this.answerType); + this.title = new Title(title); + this.problemType = problemType; + this.number = number; + this.practiceTestId = practiceTestTag != null ? practiceTestTag.getId() : null; + this.problemCustomId = problemCustomId; + this.recommendedTime = new RecommendedTime(recommendedMinute, recommendedSecond); + } + + public String getAnswer() { + return this.answer != null ? answer.getValue() : null; + } + + public void update(Problem inputProblem) { + this.problemCustomId = new ProblemCustomId(inputProblem.getProblemCustomId()); + this.practiceTestId = inputProblem.getPracticeTestId(); + this.number = inputProblem.getNumber(); + this.problemType = inputProblem.getProblemType(); + this.title = new Title(inputProblem.getTitle()); + this.answer = new Answer(inputProblem.getAnswer(), inputProblem.getAnswerType()); + this.difficulty = new Difficulty(inputProblem.getDifficulty()); + this.conceptTagIds = new HashSet<>(inputProblem.getConceptTagIds()); + this.memo = inputProblem.getMemo(); + this.mainProblemImageUrl = inputProblem.getMainProblemImageUrl(); + this.mainAnalysisImageUrl = inputProblem.getMainAnalysisImageUrl(); + this.mainHandwritingExplanationImageUrl = inputProblem.getMainHandwritingExplanationImageUrl(); + this.readingTipImageUrl = inputProblem.getReadingTipImageUrl(); + this.seniorTipImageUrl = inputProblem.getSeniorTipImageUrl(); + this.prescriptionImageUrls = inputProblem.getPrescriptionImageUrls(); + this.answerType = inputProblem.getAnswerType(); + this.recommendedTime = new RecommendedTime( + inputProblem.getRecommendedTime().getMinute(), + inputProblem.getRecommendedTime().getSecond() + ); + } + + public void updateChildProblem(List<ChildProblem> inputChildProblems) { + if (this.childProblems.size() != inputChildProblems.size()) { + throw new InvalidValueException(ErrorCode.INVALID_CHILD_PROBLEM_SIZE); + } + + for (int i = 0; i < inputChildProblems.size(); i++) { + this.childProblems.get(i).update(inputChildProblems.get(i)); + } + } + + public void addChildProblem(ChildProblem childProblem) { + if (this.isConfirmed) { + throw new InvalidValueException(ErrorCode.INVALID_CHILD_PROBLEM_SIZE); + } + this.childProblems.add(childProblem); + } + + public void deleteChildProblem(Long childProblemId) { + if (this.isConfirmed) { + throw new InvalidValueException(ErrorCode.INVALID_CHILD_PROBLEM_SIZE); + } + this.childProblems.removeIf(childProblem -> childProblem.getId().equals(childProblemId)); + } + + public boolean isValid() { + return problemCustomId != null + && practiceTestId != null + && problemType != null + && title != null && !title.getTitle().isEmpty() + && answer != null && !answer.getValue().isEmpty() + && difficulty != null && difficulty.getDifficulty() != null + && memo != null && !memo.isEmpty() + && mainProblemImageUrl != null && !mainProblemImageUrl.isEmpty() + && mainAnalysisImageUrl != null && !mainAnalysisImageUrl.isEmpty() + && mainHandwritingExplanationImageUrl != null && !mainHandwritingExplanationImageUrl.isEmpty() + && readingTipImageUrl != null && !readingTipImageUrl.isEmpty() + && seniorTipImageUrl != null && !seniorTipImageUrl.isEmpty() + && prescriptionImageUrls != null && !prescriptionImageUrls.isEmpty() + && prescriptionImageUrls.stream().allMatch(url -> url != null && !url.isEmpty()) + && answerType != null + && conceptTagIds != null && !conceptTagIds.isEmpty() + && recommendedTime != null; + } + + public String getTitle() { + return title != null ? title.getTitle() : "제목 없음"; + } + + public Integer getDifficulty() { + return difficulty != null ? difficulty.getDifficulty() : null; + } + + public String getProblemCustomId() { + return problemCustomId.getId(); + } + +} \ No newline at end of file diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/domain/problem/ProblemAdminIdService.java b/src/main/java/com/moplus/moplus_server/domain/problem/domain/problem/ProblemAdminIdService.java new file mode 100644 index 0000000..d779579 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/domain/problem/ProblemAdminIdService.java @@ -0,0 +1,61 @@ +package com.moplus.moplus_server.domain.problem.domain.problem; + +import com.moplus.moplus_server.domain.problem.domain.practiceTest.PracticeTestTag; +import com.moplus.moplus_server.domain.problem.repository.ProblemRepository; +import java.util.concurrent.atomic.AtomicInteger; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ProblemAdminIdService { + + private static final AtomicInteger SEQUENCE = new AtomicInteger(1); // XXX 값 증가를 위한 카운터 + private final ProblemRepository problemRepository; + + /* + 문제 ID 생성 로직 + C : 문제 타입 ( 1: 기출문제, 2: 변형문제, 3: 창작문제 ) + S : ( 1: 고1, 2: 고2, 3: 미적분, 4: 기하, 5: 확률과 통계, 6: 가형, 7: 나형 ) + YY: 년도 (두 자리) + MM: 월 (두 자리) + NN : 번호 (01~99) + XX : 2자리 sequence 숫자 + */ + public ProblemCustomId nextId(int number, PracticeTestTag practiceTestTag, ProblemType problemType) { + + int problemTypeCode = problemType.getCode(); // C (문제 타입) + int subject = practiceTestTag.getSubject().getCode(); // S (과목) + int year = practiceTestTag.getYear() % 100; // YY (두 자리 연도) + int month = practiceTestTag.getMonth(); // MM (두 자리 월) + + String generatedId; + int sequence; + + // 중복되지 않는 ID 찾을 때까지 반복 + do { + sequence = SEQUENCE.getAndIncrement() % 100; // 000~999 순환 + generatedId = String.format("%d%d%02d%02d%02d%02d", + problemTypeCode, subject, year, month, number, sequence); + } while (problemRepository.existsByProblemCustomId(new ProblemCustomId(generatedId))); // ID가 이미 존재하면 재생성 + + return new ProblemCustomId(generatedId); + } + + public ProblemCustomId nextId(ProblemType problemType) { + + int problemTypeCode = problemType.getCode(); // C (문제 타입) + + String generatedId; + int sequence; + + // 중복되지 않는 ID 찾을 때까지 반복 + do { + sequence = SEQUENCE.getAndIncrement() % 100; // 000~999 순환 + generatedId = String.format("%d%09d", + problemTypeCode, sequence); + } while (problemRepository.existsByProblemCustomId(new ProblemCustomId(generatedId))); // ID가 이미 존재하면 재생성 + + return new ProblemCustomId(generatedId); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/domain/problem/ProblemCustomId.java b/src/main/java/com/moplus/moplus_server/domain/problem/domain/problem/ProblemCustomId.java new file mode 100644 index 0000000..85648be --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/domain/problem/ProblemCustomId.java @@ -0,0 +1,21 @@ +package com.moplus.moplus_server.domain.problem.domain.problem; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import java.io.Serializable; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProblemCustomId implements Serializable { + + @Column(name = "problem_custom_id") + private String id; + + public ProblemCustomId(String id) { + this.id = id; + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/domain/problem/ProblemImageType.java b/src/main/java/com/moplus/moplus_server/domain/problem/domain/problem/ProblemImageType.java new file mode 100644 index 0000000..305bb72 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/domain/problem/ProblemImageType.java @@ -0,0 +1,19 @@ +package com.moplus.moplus_server.domain.problem.domain.problem; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ProblemImageType { + MAIN_PROBLEM("main-problem", "문제 이미지"), + MAIN_ANALYSIS("main-analysis", "분석 이미지"), + MAIN_HANDWRITING_EXPLANATION("main-handwriting-explanation", "손글씨 설명 이미지"), + READING_TIP("reading-tip", "읽기 팁 이미지"), + SENIOR_TIP("senior-tip", "선배 팁 이미지"), + PRESCRIPTION("prescription", "처방전 이미지"), + CHILD_PROBLEM("child-problem", "하위 문제 이미지"); + + private final String type; + private final String description; +} \ No newline at end of file diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/domain/problem/ProblemType.java b/src/main/java/com/moplus/moplus_server/domain/problem/domain/problem/ProblemType.java new file mode 100644 index 0000000..e02d101 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/domain/problem/ProblemType.java @@ -0,0 +1,16 @@ +package com.moplus.moplus_server.domain.problem.domain.problem; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ProblemType { + + GICHUL_PROBLEM("기출문제", 1), + VARIANT_PROBLEM("변형문제", 2), + CREATION_PROBLEM("창작문제", 3); + + private final String name; + private final int code; +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/domain/problem/RecommendedTime.java b/src/main/java/com/moplus/moplus_server/domain/problem/domain/problem/RecommendedTime.java new file mode 100644 index 0000000..abe8ab8 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/domain/problem/RecommendedTime.java @@ -0,0 +1,34 @@ +package com.moplus.moplus_server.domain.problem.domain.problem; + +import com.moplus.moplus_server.global.error.exception.ErrorCode; +import com.moplus.moplus_server.global.error.exception.InvalidValueException; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RecommendedTime { + @Column(name = "recommended_minute") + private Integer minute; + @Column(name = "recommended_second") + private Integer second; + + public RecommendedTime(Integer minute, Integer second) { + validateTime(minute, second); + this.minute = minute != null ? minute : 0; + this.second = second != null ? second : 0; + } + + private void validateTime(Integer minute, Integer second) { + if (minute != null && (minute < 0 || minute > 60)) { + throw new InvalidValueException(ErrorCode.INVALID_INPUT_VALUE); + } + if (second != null && (second < 0 || second > 60)) { + throw new InvalidValueException(ErrorCode.INVALID_INPUT_VALUE); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/domain/problem/Title.java b/src/main/java/com/moplus/moplus_server/domain/problem/domain/problem/Title.java new file mode 100644 index 0000000..2aa77dc --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/domain/problem/Title.java @@ -0,0 +1,26 @@ +package com.moplus.moplus_server.domain.problem.domain.problem; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Title { + + private static final String DEFAULT_TITLE = "제목 없음"; + + @Column(name = "title") + private String title; + + public Title(String title) { + this.title = verifyTitle(title); + } + + private static String verifyTitle(String title) { + return (title == null || title.trim().isEmpty()) ? DEFAULT_TITLE : title; + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/dto/request/ChildProblemDeleteRequest.java b/src/main/java/com/moplus/moplus_server/domain/problem/dto/request/ChildProblemDeleteRequest.java new file mode 100644 index 0000000..07615cb --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/dto/request/ChildProblemDeleteRequest.java @@ -0,0 +1,6 @@ +package com.moplus.moplus_server.domain.problem.dto.request; + +public record ChildProblemDeleteRequest( + Long childProblemId +) { +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/dto/request/ChildProblemPostRequest.java b/src/main/java/com/moplus/moplus_server/domain/problem/dto/request/ChildProblemPostRequest.java new file mode 100644 index 0000000..0877351 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/dto/request/ChildProblemPostRequest.java @@ -0,0 +1,15 @@ +package com.moplus.moplus_server.domain.problem.dto.request; + +import com.moplus.moplus_server.domain.problem.domain.problem.AnswerType; +import jakarta.validation.constraints.NotNull; +import java.util.Set; + +public record ChildProblemPostRequest( + String imageUrl, + AnswerType answerType, + String answer, + @NotNull(message = "컬렉션 값은 필수입니다.") + Set<Long> conceptTagIds, + int sequence +) { +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/dto/request/ChildProblemUpdateRequest.java b/src/main/java/com/moplus/moplus_server/domain/problem/dto/request/ChildProblemUpdateRequest.java new file mode 100644 index 0000000..ec78dd1 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/dto/request/ChildProblemUpdateRequest.java @@ -0,0 +1,15 @@ +package com.moplus.moplus_server.domain.problem.dto.request; + +import com.moplus.moplus_server.domain.problem.domain.problem.AnswerType; +import jakarta.validation.constraints.NotNull; +import java.util.Set; + +public record ChildProblemUpdateRequest( + Long childProblemId, + String imageUrl, + AnswerType answerType, + String answer, + @NotNull(message = "컬렉션 값은 필수입니다.") + Set<Long> conceptTagIds +) { +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/dto/request/ProblemPostRequest.java b/src/main/java/com/moplus/moplus_server/domain/problem/dto/request/ProblemPostRequest.java new file mode 100644 index 0000000..94f597f --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/dto/request/ProblemPostRequest.java @@ -0,0 +1,24 @@ +package com.moplus.moplus_server.domain.problem.dto.request; + +import com.moplus.moplus_server.domain.problem.domain.practiceTest.PracticeTestTag; +import com.moplus.moplus_server.domain.problem.domain.problem.Problem; +import com.moplus.moplus_server.domain.problem.domain.problem.ProblemCustomId; +import com.moplus.moplus_server.domain.problem.domain.problem.ProblemType; +import jakarta.validation.constraints.NotNull; + +public record ProblemPostRequest( + @NotNull(message = "문항 유형은 필수입니다") + ProblemType problemType, + Long practiceTestId, + int number +) { + public Problem toEntity(PracticeTestTag practiceTestTag, ProblemCustomId problemCustomId) { + return Problem.builder() + .problemCustomId(problemCustomId) + .practiceTestTag(practiceTestTag) + .number(number) + .title("") + .problemType(problemType) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/dto/request/ProblemUpdateRequest.java b/src/main/java/com/moplus/moplus_server/domain/problem/dto/request/ProblemUpdateRequest.java new file mode 100644 index 0000000..a5aa604 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/dto/request/ProblemUpdateRequest.java @@ -0,0 +1,32 @@ +package com.moplus.moplus_server.domain.problem.dto.request; + +import com.moplus.moplus_server.domain.problem.domain.problem.AnswerType; +import com.moplus.moplus_server.domain.problem.domain.problem.ProblemType; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import java.util.Set; + +public record ProblemUpdateRequest( + @NotNull(message = "문제 유형은 필수입니다") + ProblemType problemType, + Long practiceTestId, + int number, + @NotNull(message = "컬렉션 값은 필수입니다.") + Set<Long> conceptTagIds, + String answer, + String title, + Integer difficulty, + String memo, + String mainProblemImageUrl, + String mainAnalysisImageUrl, + String mainHandwritingExplanationImageUrl, + String readingTipImageUrl, + String seniorTipImageUrl, + List<String> prescriptionImageUrls, + AnswerType answerType, + @NotNull(message = "컬렉션 값은 필수입니다.") + List<ChildProblemUpdateRequest> updateChildProblems, + Integer recommendedMinute, + Integer recommendedSecond +) { +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/ChildProblemGetResponse.java b/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/ChildProblemGetResponse.java new file mode 100644 index 0000000..b5fc52e --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/ChildProblemGetResponse.java @@ -0,0 +1,29 @@ +package com.moplus.moplus_server.domain.problem.dto.response; + +import com.moplus.moplus_server.domain.problem.domain.childProblem.ChildProblem; +import com.moplus.moplus_server.domain.problem.domain.problem.AnswerType; +import jakarta.validation.constraints.NotNull; +import java.util.Set; +import lombok.Builder; + +@Builder +public record ChildProblemGetResponse( + @NotNull(message = "새끼 문항 ID는 필수입니다") + Long childProblemId, + String imageUrl, + AnswerType answerType, + String answer, + @NotNull(message = "컬렉션 값은 필수입니다.") + Set<Long> conceptTagIds +) { + + public static ChildProblemGetResponse of(ChildProblem childProblem) { + return ChildProblemGetResponse.builder() + .childProblemId(childProblem.getId()) + .imageUrl(childProblem.getImageUrl()) + .answerType(childProblem.getAnswerType()) + .answer(childProblem.getAnswer()) + .conceptTagIds(childProblem.getConceptTagIds()) + .build(); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/ConceptTagSearchResponse.java b/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/ConceptTagSearchResponse.java new file mode 100644 index 0000000..1229df6 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/ConceptTagSearchResponse.java @@ -0,0 +1,19 @@ +package com.moplus.moplus_server.domain.problem.dto.response; + +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class ConceptTagSearchResponse { + @NotNull(message = "개념 태그 ID는 필수입니다") + private Long id; + @NotNull(message = "개념 태그 이름은 필수입니다") + private String name; + + public ConceptTagSearchResponse(Long id, String name) { + this.id = id; + this.name = name; + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/PracticeTestTagResponse.java b/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/PracticeTestTagResponse.java new file mode 100644 index 0000000..3f2a344 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/PracticeTestTagResponse.java @@ -0,0 +1,18 @@ +package com.moplus.moplus_server.domain.problem.dto.response; + +import com.moplus.moplus_server.domain.problem.domain.practiceTest.PracticeTestTag; +import jakarta.validation.constraints.NotNull; + +public record PracticeTestTagResponse( + @NotNull(message = "기출 모의고사 태그 ID는 필수입니다") + Long id, + @NotNull(message = "기출 모의고사 태그 이름은 필수입니다") + String name +) { + public static PracticeTestTagResponse of(PracticeTestTag practiceTestTag) { + return new PracticeTestTagResponse( + practiceTestTag.getId(), + practiceTestTag.getName() + ); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/PresignedUrlResponse.java b/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/PresignedUrlResponse.java new file mode 100644 index 0000000..56a2d2e --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/PresignedUrlResponse.java @@ -0,0 +1,14 @@ +package com.moplus.moplus_server.domain.problem.dto.response; + +import jakarta.validation.constraints.NotNull; + +public record PresignedUrlResponse( + @NotNull(message = "사전 서명된 URL은 필수입니다") + String presignedUrl +) { + public static PresignedUrlResponse of(String presignedUrl) { + return new PresignedUrlResponse( + presignedUrl + ); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/ProblemGetResponse.java b/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/ProblemGetResponse.java new file mode 100644 index 0000000..06bb301 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/ProblemGetResponse.java @@ -0,0 +1,69 @@ +package com.moplus.moplus_server.domain.problem.dto.response; + +import com.moplus.moplus_server.domain.problem.domain.problem.AnswerType; +import com.moplus.moplus_server.domain.problem.domain.problem.Problem; +import com.moplus.moplus_server.domain.problem.domain.problem.ProblemType; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import java.util.Set; +import lombok.Builder; + +@Builder +public record ProblemGetResponse( + @NotNull(message = "문항 ID 필수입니다") + Long id, + @NotNull(message = "문항 custom ID는 필수입니다") + String problemCustomId, + @NotNull(message = "컬렉션 값은 필수입니다.") + Set<Long> conceptTagIds, + boolean isConfirmed, + Long practiceTestId, + int number, + Integer difficulty, + String title, + String answer, + String memo, + ProblemType problemType, + AnswerType answerType, + String mainProblemImageUrl, + String mainHandwritingExplanationImageUrl, + String mainAnalysisImageUrl, + String readingTipImageUrl, + String seniorTipImageUrl, + @NotNull(message = "컬렉션 값은 필수입니다.") + List<String> prescriptionImageUrls, + @NotNull(message = "컬렉션 값은 필수입니다.") + List<ChildProblemGetResponse> childProblems, + Integer recommendedMinute, + Integer recommendedSecond +) { + + public static ProblemGetResponse of(Problem problem) { + + return ProblemGetResponse.builder() + .id(problem.getId()) + .problemCustomId(problem.getProblemCustomId()) + .conceptTagIds(problem.getConceptTagIds()) + .isConfirmed(problem.isConfirmed()) + .practiceTestId(problem.getPracticeTestId()) + .number(problem.getNumber()) + .answer(problem.getAnswer()) + .title(problem.getTitle()) + .difficulty(problem.getDifficulty()) + .memo(problem.getMemo()) + .problemType(problem.getProblemType()) + .answerType(problem.getAnswerType()) + .mainProblemImageUrl(problem.getMainProblemImageUrl()) + .mainHandwritingExplanationImageUrl(problem.getMainHandwritingExplanationImageUrl()) + .mainAnalysisImageUrl(problem.getMainAnalysisImageUrl()) + .readingTipImageUrl(problem.getReadingTipImageUrl()) + .seniorTipImageUrl(problem.getSeniorTipImageUrl()) + .prescriptionImageUrls(problem.getPrescriptionImageUrls()) + .childProblems(problem.getChildProblems().stream() + .map(ChildProblemGetResponse::of) + .toList()) + .recommendedMinute(problem.getRecommendedTime().getMinute()) + .recommendedSecond(problem.getRecommendedTime().getSecond()) + .build(); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/ProblemPostResponse.java b/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/ProblemPostResponse.java new file mode 100644 index 0000000..5715c5c --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/ProblemPostResponse.java @@ -0,0 +1,15 @@ +package com.moplus.moplus_server.domain.problem.dto.response; + +import com.moplus.moplus_server.domain.problem.domain.problem.Problem; +import jakarta.validation.constraints.NotNull; + +public record ProblemPostResponse( + @NotNull(message = "문항 ID는 필수입니다") + Long id, + @NotNull(message = "문항 custom ID는 필수입니다") + String problemCustomId +) { + public static ProblemPostResponse of(Problem problem) { + return new ProblemPostResponse(problem.getId(), problem.getProblemCustomId()); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/ProblemSearchGetResponse.java b/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/ProblemSearchGetResponse.java new file mode 100644 index 0000000..b2a47ef --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/ProblemSearchGetResponse.java @@ -0,0 +1,36 @@ +package com.moplus.moplus_server.domain.problem.dto.response; + +import jakarta.validation.constraints.NotNull; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class ProblemSearchGetResponse { + @NotNull(message = "문항 ID는 필수입니다") + private Long problemId; + @NotNull(message = "문항 custom ID는 필수입니다") + private String problemCustomId; + private String problemTitle; + private String memo; + private String mainProblemImageUrl; + @NotNull(message = "개념 태그리스트는 필수입니다") + private Set<String> tagNames; + + public ProblemSearchGetResponse(Long problemId, String problemCustomId, String problemTitle, String memo, + String mainProblemImageUrl, + Set<ConceptTagSearchResponse> tagNames) { + this.problemId = problemId; + this.problemCustomId = problemCustomId; + this.problemTitle = problemTitle; + this.memo = memo; + this.mainProblemImageUrl = mainProblemImageUrl; + this.tagNames = tagNames.stream() + .map(ConceptTagSearchResponse::getName) + .collect(Collectors.toSet()); + } +} + + diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/repository/ChildProblemRepository.java b/src/main/java/com/moplus/moplus_server/domain/problem/repository/ChildProblemRepository.java new file mode 100644 index 0000000..9b28686 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/repository/ChildProblemRepository.java @@ -0,0 +1,13 @@ +package com.moplus.moplus_server.domain.problem.repository; + +import com.moplus.moplus_server.domain.problem.domain.childProblem.ChildProblem; +import com.moplus.moplus_server.global.error.exception.ErrorCode; +import com.moplus.moplus_server.global.error.exception.NotFoundException; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ChildProblemRepository extends JpaRepository<ChildProblem, Long> { + + default ChildProblem findByIdElseThrow(Long childProblemId) { + return findById(childProblemId).orElseThrow(() -> new NotFoundException(ErrorCode.CHILD_PROBLEM_NOT_FOUND)); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/repository/PracticeTestTagRepository.java b/src/main/java/com/moplus/moplus_server/domain/problem/repository/PracticeTestTagRepository.java new file mode 100644 index 0000000..2e02d64 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/repository/PracticeTestTagRepository.java @@ -0,0 +1,14 @@ +package com.moplus.moplus_server.domain.problem.repository; + +import com.moplus.moplus_server.domain.problem.domain.practiceTest.PracticeTestTag; +import com.moplus.moplus_server.global.error.exception.ErrorCode; +import com.moplus.moplus_server.global.error.exception.NotFoundException; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PracticeTestTagRepository extends JpaRepository<PracticeTestTag, Long> { + + default PracticeTestTag findByIdElseThrow(Long id) { + return findById(id) + .orElseThrow(() -> new NotFoundException(ErrorCode.PRACTICE_TEST_NOT_FOUND)); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/repository/ProblemRepository.java b/src/main/java/com/moplus/moplus_server/domain/problem/repository/ProblemRepository.java new file mode 100644 index 0000000..4529f68 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/repository/ProblemRepository.java @@ -0,0 +1,28 @@ +package com.moplus.moplus_server.domain.problem.repository; + +import com.moplus.moplus_server.domain.problem.domain.problem.Problem; +import com.moplus.moplus_server.domain.problem.domain.problem.ProblemCustomId; +import com.moplus.moplus_server.global.error.exception.ErrorCode; +import com.moplus.moplus_server.global.error.exception.NotFoundException; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProblemRepository extends JpaRepository<Problem, Long> { + + boolean existsByProblemCustomId(ProblemCustomId problemCustomId); + + default void existsByIdElseThrow(Long id) { + if (!existsById(id)) { + throw new NotFoundException(ErrorCode.PROBLEM_NOT_FOUND); + } + } + + default void existsByProblemAdminIdElseThrow(ProblemCustomId problemCustomId) { + if (!existsByProblemCustomId(problemCustomId)) { + throw new NotFoundException(ErrorCode.PROBLEM_NOT_FOUND); + } + } + + default Problem findByIdElseThrow(Long id) { + return findById(id).orElseThrow(() -> new NotFoundException(ErrorCode.PROBLEM_NOT_FOUND)); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/repository/ProblemSearchRepositoryCustom.java b/src/main/java/com/moplus/moplus_server/domain/problem/repository/ProblemSearchRepositoryCustom.java new file mode 100644 index 0000000..803ef2e --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/repository/ProblemSearchRepositoryCustom.java @@ -0,0 +1,97 @@ +package com.moplus.moplus_server.domain.problem.repository; + +import static com.moplus.moplus_server.domain.concept.domain.QConceptTag.conceptTag; +import static com.moplus.moplus_server.domain.problem.domain.childProblem.QChildProblem.childProblem; +import static com.moplus.moplus_server.domain.problem.domain.problem.QProblem.problem; + +import com.moplus.moplus_server.domain.problem.dto.response.ConceptTagSearchResponse; +import com.moplus.moplus_server.domain.problem.dto.response.ProblemSearchGetResponse; +import com.querydsl.core.group.GroupBy; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class ProblemSearchRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + public List<ProblemSearchGetResponse> search(String problemId, String title, List<Long> conceptTagIds) { + return queryFactory + .select(problem.problemCustomId.id, problem.title, problem.mainProblemImageUrl) + .from(problem) + .leftJoin(childProblem).on(childProblem.in(problem.childProblems)) + .leftJoin(conceptTag).on(conceptTag.id.in(problem.conceptTagIds) + .or(conceptTag.id.in(childProblem.conceptTagIds))) + .where( + containsProblemId(problemId), + containsName(title), + hasConceptTags(conceptTagIds) + ) + .distinct() + .transform(GroupBy.groupBy(problem.id).list( + Projections.constructor(ProblemSearchGetResponse.class, + problem.id, + problem.problemCustomId.id, + problem.title.title, + problem.memo, + problem.mainProblemImageUrl, + GroupBy.set( + Projections.constructor(ConceptTagSearchResponse.class, + conceptTag.id, + conceptTag.name + ) + ) + ) + )); + } + + private BooleanExpression hasConceptTags(List<Long> conceptTagIds) { + if (conceptTagIds == null || conceptTagIds.isEmpty()) { + return null; + } + + return problem.id.in( + JPAExpressions + .selectFrom(problem) + .where( + problem.conceptTagIds.any().in(conceptTagIds) + .or( + problem.id.in( + JPAExpressions + .select(childProblem.id) + .from(childProblem) + .where(childProblem.conceptTagIds.any() + .in(conceptTagIds)) + ) + ) + ) + .select(problem.id) + ); + } + + //problemCustomId 일부 포함 검색 + private BooleanExpression containsProblemId(String problemId) { + return (problemId == null || problemId.isEmpty()) ? null + : problem.problemCustomId.id.containsIgnoreCase(problemId); + } + + //name 조건 (포함 검색) + private BooleanExpression containsName(String title) { + if (title == null || title.trim().isEmpty()) { + return null; + } + return problem.title.title.containsIgnoreCase(title.trim()); + } + + //conceptTagIds 조건 (하나라도 포함되면 조회) + private BooleanExpression inConceptTagIds(List<Long> conceptTagIds) { + return (conceptTagIds == null || conceptTagIds.isEmpty()) ? null + : problem.conceptTagIds.any().in(conceptTagIds); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/repository/converter/StringListConverter.java b/src/main/java/com/moplus/moplus_server/domain/problem/repository/converter/StringListConverter.java new file mode 100644 index 0000000..9ca476b --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/repository/converter/StringListConverter.java @@ -0,0 +1,41 @@ +package com.moplus.moplus_server.domain.problem.repository.converter; + +import com.moplus.moplus_server.global.error.exception.ErrorCode; +import com.moplus.moplus_server.global.error.exception.InvalidValueException; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Converter +public class StringListConverter implements AttributeConverter<List<String>, String> { + + private static final String DELIMITER = ", "; + + @Override + public String convertToDatabaseColumn(List<String> attribute) { + try { + if (attribute == null || attribute.isEmpty()) { + return ""; + } + return attribute.stream() + .filter(str -> str != null && !str.isEmpty()) + .collect(Collectors.joining(DELIMITER)); + } catch (Exception e) { + throw new InvalidValueException(ErrorCode.INVALID_INPUT_VALUE); + } + } + + @Override + public List<String> convertToEntityAttribute(String dbData) { + if (dbData == null || dbData.isEmpty()) { + return List.of(); + } + return Arrays.stream(dbData.split(DELIMITER)) + .filter(str -> str != null && !str.isEmpty()) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/service/ChildProblemService.java b/src/main/java/com/moplus/moplus_server/domain/problem/service/ChildProblemService.java new file mode 100644 index 0000000..bdc1f6a --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/service/ChildProblemService.java @@ -0,0 +1,41 @@ +package com.moplus.moplus_server.domain.problem.service; + +import com.moplus.moplus_server.domain.problem.domain.childProblem.ChildProblem; +import com.moplus.moplus_server.domain.problem.domain.problem.Problem; +import com.moplus.moplus_server.domain.problem.repository.ChildProblemRepository; +import com.moplus.moplus_server.domain.problem.repository.ProblemRepository; +import com.moplus.moplus_server.global.error.exception.ErrorCode; +import com.moplus.moplus_server.global.error.exception.InvalidValueException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ChildProblemService { + + private final ProblemRepository problemRepository; + private final ChildProblemRepository childProblemRepository; + + @Transactional + public Long createChildProblem(Long problemId) { + Problem problem = problemRepository.findByIdElseThrow(problemId); + if (problem.isConfirmed()) { + throw new InvalidValueException(ErrorCode.CHILD_PROBLEM_UPDATE_AFTER_CONFIRMED); + } + + problem.addChildProblem(ChildProblem.createEmptyChildProblem()); + + return problemRepository.save(problem).getChildProblems().get(problem.getChildProblems().size() - 1).getId(); + } + + @Transactional + public void deleteChildProblem(Long problemId, Long childProblemId) { + Problem problem = problemRepository.findByIdElseThrow(problemId); + if (problem.isConfirmed()) { + throw new InvalidValueException(ErrorCode.CHILD_PROBLEM_UPDATE_AFTER_CONFIRMED); + } + problem.deleteChildProblem(childProblemId); + problemRepository.save(problem); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/service/ImageUploadService.java b/src/main/java/com/moplus/moplus_server/domain/problem/service/ImageUploadService.java new file mode 100644 index 0000000..634f602 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/service/ImageUploadService.java @@ -0,0 +1,38 @@ +package com.moplus.moplus_server.domain.problem.service; + +import com.amazonaws.HttpMethod; +import com.moplus.moplus_server.domain.problem.domain.problem.ProblemImageType; +import com.moplus.moplus_server.domain.problem.repository.ProblemRepository; +import com.moplus.moplus_server.global.utils.s3.S3Util; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ImageUploadService { + + private static final String PROBLEM_IMAGE_PREFIX = "problems/"; + private final S3Util s3Util; + private final ProblemRepository problemRepository; + + public String generateProblemImagePresignedUrl(Long problemId, ProblemImageType imageType) { + problemRepository.existsByIdElseThrow(problemId); + String fileName = generateProblemImageFileName(problemId, imageType); + return s3Util.getS3PresignedUrl(fileName, HttpMethod.PUT); + } + + private String generateProblemImageFileName(Long problemId, ProblemImageType imageType) { + String uuid = UUID.randomUUID().toString(); + return String.format("%s%d/%s/%s.jpg", + PROBLEM_IMAGE_PREFIX, + problemId, + imageType.getType(), + uuid + ); + } + + public String getImageUrl(String fileName) { + return s3Util.getS3ObjectUrl(fileName); + } +} \ No newline at end of file diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/service/ProblemDeleteService.java b/src/main/java/com/moplus/moplus_server/domain/problem/service/ProblemDeleteService.java new file mode 100644 index 0000000..dc9c92d --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/service/ProblemDeleteService.java @@ -0,0 +1,19 @@ +package com.moplus.moplus_server.domain.problem.service; + +import com.moplus.moplus_server.domain.problem.repository.ProblemRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ProblemDeleteService { + + private final ProblemRepository problemRepository; + + @Transactional + public void deleteProblem(Long id) { + problemRepository.existsByIdElseThrow(id); + problemRepository.deleteById(id); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/service/ProblemGetService.java b/src/main/java/com/moplus/moplus_server/domain/problem/service/ProblemGetService.java new file mode 100644 index 0000000..a3b0fa1 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/service/ProblemGetService.java @@ -0,0 +1,21 @@ +package com.moplus.moplus_server.domain.problem.service; + +import com.moplus.moplus_server.domain.problem.domain.problem.Problem; +import com.moplus.moplus_server.domain.problem.dto.response.ProblemGetResponse; +import com.moplus.moplus_server.domain.problem.repository.ProblemRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ProblemGetService { + + private final ProblemRepository problemRepository; + + @Transactional(readOnly = true) + public ProblemGetResponse getProblem(Long problemId) { + Problem problem = problemRepository.findByIdElseThrow(problemId); + return ProblemGetResponse.of(problem); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/service/ProblemSaveService.java b/src/main/java/com/moplus/moplus_server/domain/problem/service/ProblemSaveService.java new file mode 100644 index 0000000..668233a --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/service/ProblemSaveService.java @@ -0,0 +1,43 @@ +package com.moplus.moplus_server.domain.problem.service; + +import com.moplus.moplus_server.domain.problem.domain.practiceTest.PracticeTestTag; +import com.moplus.moplus_server.domain.problem.domain.problem.Problem; +import com.moplus.moplus_server.domain.problem.domain.problem.ProblemAdminIdService; +import com.moplus.moplus_server.domain.problem.domain.problem.ProblemCustomId; +import com.moplus.moplus_server.domain.problem.domain.problem.ProblemType; +import com.moplus.moplus_server.domain.problem.dto.request.ProblemPostRequest; +import com.moplus.moplus_server.domain.problem.dto.response.ProblemPostResponse; +import com.moplus.moplus_server.domain.problem.repository.PracticeTestTagRepository; +import com.moplus.moplus_server.domain.problem.repository.ProblemRepository; +import com.moplus.moplus_server.domain.problem.service.mapper.ProblemMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ProblemSaveService { + + private final ProblemRepository problemRepository; + private final PracticeTestTagRepository practiceTestRepository; + private final ProblemAdminIdService problemAdminIdService; + private final ProblemMapper problemMapper; + + @Transactional + public ProblemPostResponse createProblem(ProblemPostRequest request) { + Problem problem; + if (request.problemType() != ProblemType.CREATION_PROBLEM) { + PracticeTestTag practiceTestTag = practiceTestRepository.findByIdElseThrow(request.practiceTestId()); + ProblemCustomId problemCustomId = problemAdminIdService.nextId(request.number(), practiceTestTag, + request.problemType()); + + problem = problemMapper.from(request, problemCustomId, practiceTestTag); + } else { + ProblemCustomId problemCustomId = problemAdminIdService.nextId(request.problemType()); + problem = problemMapper.from(request.problemType(), problemCustomId); + } + + return ProblemPostResponse.of(problemRepository.save(problem)); + } + +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/service/ProblemUpdateService.java b/src/main/java/com/moplus/moplus_server/domain/problem/service/ProblemUpdateService.java new file mode 100644 index 0000000..423fd3b --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/service/ProblemUpdateService.java @@ -0,0 +1,54 @@ +package com.moplus.moplus_server.domain.problem.service; + +import com.moplus.moplus_server.domain.concept.repository.ConceptTagRepository; +import com.moplus.moplus_server.domain.problem.domain.childProblem.ChildProblem; +import com.moplus.moplus_server.domain.problem.domain.practiceTest.PracticeTestTag; +import com.moplus.moplus_server.domain.problem.domain.problem.Problem; +import com.moplus.moplus_server.domain.problem.domain.problem.ProblemAdminIdService; +import com.moplus.moplus_server.domain.problem.domain.problem.ProblemCustomId; +import com.moplus.moplus_server.domain.problem.dto.request.ProblemUpdateRequest; +import com.moplus.moplus_server.domain.problem.dto.response.ProblemGetResponse; +import com.moplus.moplus_server.domain.problem.repository.PracticeTestTagRepository; +import com.moplus.moplus_server.domain.problem.repository.ProblemRepository; +import com.moplus.moplus_server.domain.problem.service.mapper.ChildProblemMapper; +import com.moplus.moplus_server.domain.problem.service.mapper.ProblemMapper; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ProblemUpdateService { + + private final ProblemRepository problemRepository; + private final ProblemAdminIdService problemAdminIdService; + private final PracticeTestTagRepository practiceTestRepository; + private final ConceptTagRepository conceptTagRepository; + private final ChildProblemMapper childProblemMapper; + private final ProblemMapper problemMapper; + + @Transactional + public ProblemGetResponse updateProblem(Long problemId, ProblemUpdateRequest request) { + PracticeTestTag practiceTestTag = practiceTestRepository.findByIdElseThrow(request.practiceTestId()); + conceptTagRepository.existsByIdElseThrow(request.conceptTagIds()); + Problem problem = problemRepository.findByIdElseThrow(problemId); + + ProblemCustomId problemCustomId = problemAdminIdService.nextId(request.number(), practiceTestTag, + request.problemType()); + + Problem inputProblem = problemMapper.from(request, problemCustomId, practiceTestTag); + problem.update(inputProblem); + + List<ChildProblem> childProblems = changeToChildProblems(request); + problem.updateChildProblem(childProblems); + + return ProblemGetResponse.of(problemRepository.save(problem)); + } + + private List<ChildProblem> changeToChildProblems(ProblemUpdateRequest request) { + return request.updateChildProblems().stream() + .map(childProblemMapper::from) + .toList(); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/service/mapper/ChildProblemMapper.java b/src/main/java/com/moplus/moplus_server/domain/problem/service/mapper/ChildProblemMapper.java new file mode 100644 index 0000000..b48abe5 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/service/mapper/ChildProblemMapper.java @@ -0,0 +1,16 @@ +package com.moplus.moplus_server.domain.problem.service.mapper; + +import com.moplus.moplus_server.domain.problem.domain.childProblem.ChildProblem; +import com.moplus.moplus_server.domain.problem.dto.request.ChildProblemPostRequest; +import com.moplus.moplus_server.domain.problem.dto.request.ChildProblemUpdateRequest; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface ChildProblemMapper { + + ChildProblem from(ChildProblemPostRequest request); + + @Mapping(target = "id", source = "childProblemId") + ChildProblem from(ChildProblemUpdateRequest request); +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/service/mapper/ProblemMapper.java b/src/main/java/com/moplus/moplus_server/domain/problem/service/mapper/ProblemMapper.java new file mode 100644 index 0000000..2f67b1d --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problem/service/mapper/ProblemMapper.java @@ -0,0 +1,34 @@ +package com.moplus.moplus_server.domain.problem.service.mapper; + +import com.moplus.moplus_server.domain.problem.domain.practiceTest.PracticeTestTag; +import com.moplus.moplus_server.domain.problem.domain.problem.Problem; +import com.moplus.moplus_server.domain.problem.domain.problem.ProblemCustomId; +import com.moplus.moplus_server.domain.problem.domain.problem.ProblemType; +import com.moplus.moplus_server.domain.problem.dto.request.ProblemPostRequest; +import com.moplus.moplus_server.domain.problem.dto.request.ProblemUpdateRequest; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Mappings; + +@Mapper(componentModel = "spring") +public interface ProblemMapper { + + @Mappings({ + @Mapping(target = "problemCustomId", source = "problemCustomId"), + @Mapping(target = "practiceTestTag", source = "practiceTestTag"), + }) + Problem from(ProblemPostRequest request, ProblemCustomId problemCustomId, PracticeTestTag practiceTestTag); + + @Mappings({ + @Mapping(target = "problemCustomId", source = "problemCustomId"), + @Mapping(target = "practiceTestTag", source = "practiceTestTag"), + @Mapping(target = "recommendedMinute", source = "request.recommendedMinute"), + @Mapping(target = "recommendedSecond", source = "request.recommendedSecond") + }) + Problem from(ProblemUpdateRequest request, ProblemCustomId problemCustomId, PracticeTestTag practiceTestTag); + + @Mappings({ + @Mapping(target = "problemCustomId", source = "problemCustomId") + }) + Problem from(ProblemType problemType, ProblemCustomId problemCustomId); +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problemset/controller/ProblemSetController.java b/src/main/java/com/moplus/moplus_server/domain/problemset/controller/ProblemSetController.java new file mode 100644 index 0000000..c78c9f3 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problemset/controller/ProblemSetController.java @@ -0,0 +1,88 @@ +package com.moplus.moplus_server.domain.problemset.controller; + +import com.moplus.moplus_server.domain.problemset.domain.ProblemSetConfirmStatus; +import com.moplus.moplus_server.domain.problemset.dto.request.ProblemReorderRequest; +import com.moplus.moplus_server.domain.problemset.dto.request.ProblemSetUpdateRequest; +import com.moplus.moplus_server.domain.problemset.dto.response.ProblemSetGetResponse; +import com.moplus.moplus_server.domain.problemset.service.ProblemSetDeleteService; +import com.moplus.moplus_server.domain.problemset.service.ProblemSetGetService; +import com.moplus.moplus_server.domain.problemset.service.ProblemSetSaveService; +import com.moplus.moplus_server.domain.problemset.service.ProblemSetUpdateService; +import com.moplus.moplus_server.global.response.IdResponse; +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +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.RestController; + +@Tag(name = "문항세트", description = "문항세트 관련 API") +@RestController +@RequestMapping("/api/v1/problemSet") +@RequiredArgsConstructor +public class ProblemSetController { + + private final ProblemSetSaveService problemSetSaveService; + private final ProblemSetUpdateService problemSetUpdateService; + private final ProblemSetDeleteService problemSetDeleteService; + private final ProblemSetGetService problemSetGetService; + + @PostMapping("") + @Operation(summary = "문항세트 생성", description = "문항세트를 생성합니다. 문항은 요청 순서대로 저장합니다.") + public ResponseEntity<IdResponse> createProblemSet( + ) { + return ResponseEntity.ok(new IdResponse(problemSetSaveService.createProblemSet())); + } + + @Hidden + @PutMapping("/{problemSetId}/sequence") + @Operation(summary = "세트 문항순서 변경", description = "문항세트 내의 문항 리스트의 순서를 변경합니다.") + public ResponseEntity<Void> reorderProblems( + @PathVariable Long problemSetId, + @RequestBody ProblemReorderRequest request) { + problemSetUpdateService.reorderProblems(problemSetId, request); + return ResponseEntity.noContent().build(); + } + + @PutMapping("/{problemSetId}") + @Operation(summary = "문항세트 수정", description = "문항세트의 이름 및 문항 리스트를 수정합니다.") + public ResponseEntity<Void> updateProblemSet( + @PathVariable Long problemSetId, + @RequestBody ProblemSetUpdateRequest request + ) { + problemSetUpdateService.updateProblemSet(problemSetId, request); + return ResponseEntity.ok(null); + } + + @DeleteMapping("/{problemSetId}") + @Operation(summary = "문항세트 삭제", description = "문항세트를 삭제합니다. (soft delete)") + public ResponseEntity<Void> deleteProblemSet( + @PathVariable Long problemSetId + ) { + problemSetDeleteService.deleteProblemSet(problemSetId); + return ResponseEntity.ok(null); + } + + @PutMapping("/{problemSetId}/confirm") + @Operation(summary = "문항세트 컨펌 토글", description = "문항세트의 컨펌 상태를 토글합니다.") + public ResponseEntity<ProblemSetConfirmStatus> toggleConfirmProblemSet( + @PathVariable Long problemSetId + ) { + return ResponseEntity.ok(problemSetUpdateService.toggleConfirmProblemSet(problemSetId)); + } + + @GetMapping("/{problemSetId}") + @Operation(summary = "문항세트 개별 조회", description = "문항세트를 조회합니다.") + public ResponseEntity<ProblemSetGetResponse> getProblemSet( + @PathVariable Long problemSetId + ) { + return ResponseEntity.ok(problemSetGetService.getProblemSet(problemSetId)); + } +} \ No newline at end of file diff --git a/src/main/java/com/moplus/moplus_server/domain/problemset/controller/ProblemSetSearchController.java b/src/main/java/com/moplus/moplus_server/domain/problemset/controller/ProblemSetSearchController.java new file mode 100644 index 0000000..0b65a47 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problemset/controller/ProblemSetSearchController.java @@ -0,0 +1,51 @@ +package com.moplus.moplus_server.domain.problemset.controller; + + +import com.moplus.moplus_server.domain.problemset.dto.response.ProblemSetSearchGetResponse; +import com.moplus.moplus_server.domain.problemset.repository.ProblemSetSearchRepositoryCustom; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/problemSet") +@RequiredArgsConstructor +public class ProblemSetSearchController { + + private final ProblemSetSearchRepositoryCustom problemSetSearchRepository; + + @Tag(name = "문항세트", description = "문항세트 관련 API") + @GetMapping("/search") + @Operation( + summary = "문항세트 검색", + description = "문항세트 타이틀, 문항세트 내 포함된 개념태그, 문항세트 내 포함된 문항 타이틀로 검색합니다. 발행상태는 발행이면 CONFIRMED, 아니면 NOT_CONFIRMED 입니다." + ) + public ResponseEntity<List<ProblemSetSearchGetResponse>> search( + @RequestParam(value = "problemSetTitle", required = false) String problemSetTitle, + @RequestParam(value = "problemTitle", required = false) String problemTitle + ) { + List<ProblemSetSearchGetResponse> problemSets = problemSetSearchRepository.search(problemSetTitle, problemTitle); + return ResponseEntity.ok(problemSets); + } + + @Tag(name = "발행", description = "발행 관련 API") + @GetMapping("/confirm/search") + @Operation( + summary = "발행용 문항세트 검색", + description = "문항세트 타이틀, 문항세트 내 포함된 개념태그, 문항세트 내 포함된 문항 타이틀로 검색합니다. 발행상태가 CONFIRMED 문항세트만 조회됩니다.." + ) + public ResponseEntity<List<ProblemSetSearchGetResponse>> confirmSearch( + @RequestParam(value = "problemSetTitle", required = false) String problemSetTitle, + @RequestParam(value = "problemTitle", required = false) String problemTitle + ) { + List<ProblemSetSearchGetResponse> problemSets = problemSetSearchRepository.confirmSearch(problemSetTitle, + problemTitle); + return ResponseEntity.ok(problemSets); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problemset/domain/ProblemSet.java b/src/main/java/com/moplus/moplus_server/domain/problemset/domain/ProblemSet.java new file mode 100644 index 0000000..077be3f --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problemset/domain/ProblemSet.java @@ -0,0 +1,94 @@ +package com.moplus.moplus_server.domain.problemset.domain; + +import com.moplus.moplus_server.domain.problem.domain.problem.Problem; +import com.moplus.moplus_server.global.common.BaseEntity; +import com.moplus.moplus_server.global.error.exception.ErrorCode; +import com.moplus.moplus_server.global.error.exception.InvalidValueException; +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OrderColumn; +import java.util.ArrayList; +import java.util.List; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProblemSet extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "problem_set_id") + Long id; + + @Embedded + private Title title; + + @Column(nullable = false) + private boolean isDeleted; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ProblemSetConfirmStatus confirmStatus; + + @ElementCollection + @CollectionTable(name = "problem_set_problems", joinColumns = @JoinColumn(name = "problem_set_id")) + @OrderColumn(name = "sequence") + @Column(name = "problem_id", nullable = false) + private List<Long> problemIds = new ArrayList<>(); + + @Builder + public ProblemSet(String title, List<Long> problemIds) { + this.title = new Title(title); + this.isDeleted = false; + this.confirmStatus = ProblemSetConfirmStatus.NOT_CONFIRMED; + this.problemIds = problemIds; + } + + public static ProblemSet ofEmptyProblemSet() { + return ProblemSet.builder() + .title("") + .problemIds(new ArrayList<>()) + .build(); + } + + public void updateProblemOrder(List<Long> newProblems) { + this.problemIds = new ArrayList<>(newProblems); + } + + public void deleteProblemSet() { + this.isDeleted = true; + } + + public void toggleConfirm(List<Problem> problems) { + if (this.confirmStatus == ProblemSetConfirmStatus.NOT_CONFIRMED) { + List<String> invalidProblemIds = problems.stream() + .filter(problem -> !problem.isValid()) + .map(Problem::getProblemCustomId) + .toList(); + if (!invalidProblemIds.isEmpty()) { + String message = ErrorCode.INVALID_CONFIRM_PROBLEM.getMessage() + + String.join("번 ", invalidProblemIds) + "번"; + throw new InvalidValueException(message, ErrorCode.INVALID_CONFIRM_PROBLEM); + } + } + this.confirmStatus = this.confirmStatus.toggle(); + } + + public void updateProblemSet(String title, List<Long> newProblems) { + this.title = new Title(title); + this.problemIds = newProblems; + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problemset/domain/ProblemSetConfirmStatus.java b/src/main/java/com/moplus/moplus_server/domain/problemset/domain/ProblemSetConfirmStatus.java new file mode 100644 index 0000000..e108d26 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problemset/domain/ProblemSetConfirmStatus.java @@ -0,0 +1,9 @@ +package com.moplus.moplus_server.domain.problemset.domain; + +public enum ProblemSetConfirmStatus { + CONFIRMED, + NOT_CONFIRMED; + public ProblemSetConfirmStatus toggle() { + return this == CONFIRMED ? NOT_CONFIRMED : CONFIRMED; + } +} \ No newline at end of file diff --git a/src/main/java/com/moplus/moplus_server/domain/problemset/domain/Title.java b/src/main/java/com/moplus/moplus_server/domain/problemset/domain/Title.java new file mode 100644 index 0000000..aa91e1c --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problemset/domain/Title.java @@ -0,0 +1,25 @@ +package com.moplus.moplus_server.domain.problemset.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@NoArgsConstructor +public class Title { + + private static final String DEFAULT_TITLE = "제목 없음"; + + @Column(name = "title", nullable = false) + private String value; + + public Title(String title) { + this.value = verifyTitle(title); + } + + private static String verifyTitle(String title) { + return (title == null || title.trim().isEmpty()) ? DEFAULT_TITLE : title; + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problemset/dto/request/ProblemReorderRequest.java b/src/main/java/com/moplus/moplus_server/domain/problemset/dto/request/ProblemReorderRequest.java new file mode 100644 index 0000000..bd4e0fa --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problemset/dto/request/ProblemReorderRequest.java @@ -0,0 +1,10 @@ +package com.moplus.moplus_server.domain.problemset.dto.request; + +import jakarta.validation.constraints.NotNull; +import java.util.List; + +public record ProblemReorderRequest( + @NotNull(message = "컬렉션 값은 필수입니다.") + List<Long> newProblems +) { +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problemset/dto/request/ProblemSetPostRequest.java b/src/main/java/com/moplus/moplus_server/domain/problemset/dto/request/ProblemSetPostRequest.java new file mode 100644 index 0000000..4b8a872 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problemset/dto/request/ProblemSetPostRequest.java @@ -0,0 +1,18 @@ +package com.moplus.moplus_server.domain.problemset.dto.request; + +import com.moplus.moplus_server.domain.problemset.domain.ProblemSet; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +public record ProblemSetPostRequest( + String problemSetTitle, + @NotNull(message = "컬렉션 값은 필수입니다.") + List<Long> problems +) { + public ProblemSet toEntity(List<Long> problemIdList) { + return ProblemSet.builder() + .title(this.problemSetTitle) + .problemIds(problemIdList) + .build(); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problemset/dto/request/ProblemSetUpdateRequest.java b/src/main/java/com/moplus/moplus_server/domain/problemset/dto/request/ProblemSetUpdateRequest.java new file mode 100644 index 0000000..97d6d48 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problemset/dto/request/ProblemSetUpdateRequest.java @@ -0,0 +1,11 @@ +package com.moplus.moplus_server.domain.problemset.dto.request; + +import jakarta.validation.constraints.NotNull; +import java.util.List; + +public record ProblemSetUpdateRequest( + String problemSetTitle, + @NotNull(message = "컬렉션 값은 필수입니다.") + List<Long> problemIds +) { +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problemset/dto/response/ProblemSetGetResponse.java b/src/main/java/com/moplus/moplus_server/domain/problemset/dto/response/ProblemSetGetResponse.java new file mode 100644 index 0000000..c4c47fa --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problemset/dto/response/ProblemSetGetResponse.java @@ -0,0 +1,31 @@ +package com.moplus.moplus_server.domain.problemset.dto.response; + +import com.moplus.moplus_server.domain.problemset.domain.ProblemSet; +import com.moplus.moplus_server.domain.problemset.domain.ProblemSetConfirmStatus; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; +import java.util.List; +import lombok.Builder; + +@Builder +public record ProblemSetGetResponse( + Long id, + String title, + ProblemSetConfirmStatus confirmStatus, + @NotNull(message = "컬렉션 값은 필수입니다.") + List<LocalDate> publishedDates, + @NotNull(message = "컬렉션 값은 필수입니다.") + List<ProblemSummaryResponse> problemSummaries +) { + public static ProblemSetGetResponse of(ProblemSet problemSet, List<LocalDate> publishedDates, + List<ProblemSummaryResponse> problemSummaries) { + + return ProblemSetGetResponse.builder() + .id(problemSet.getId()) + .title(problemSet.getTitle().getValue()) + .confirmStatus(problemSet.getConfirmStatus()) + .publishedDates(publishedDates) + .problemSummaries(problemSummaries) + .build(); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problemset/dto/response/ProblemSetSearchGetResponse.java b/src/main/java/com/moplus/moplus_server/domain/problemset/dto/response/ProblemSetSearchGetResponse.java new file mode 100644 index 0000000..dd2eef1 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problemset/dto/response/ProblemSetSearchGetResponse.java @@ -0,0 +1,28 @@ +package com.moplus.moplus_server.domain.problemset.dto.response; + +import com.moplus.moplus_server.domain.problemset.domain.ProblemSetConfirmStatus; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class ProblemSetSearchGetResponse { + @NotNull(message = "ID 값은 필수입니다.") + private Long id; + private String problemSetTitle; + private ProblemSetConfirmStatus confirmStatus; + @NotNull(message = "컬렉션 값은 필수입니다.") + private List<ProblemThumbnailResponse> problemThumbnailResponses; + + public ProblemSetSearchGetResponse( + Long id, String problemSetTitle, ProblemSetConfirmStatus confirmStatus, + List<ProblemThumbnailResponse> problemThumbnailResponses + ) { + this.id = id; + this.problemSetTitle = problemSetTitle; + this.confirmStatus = confirmStatus; + this.problemThumbnailResponses = problemThumbnailResponses; + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problemset/dto/response/ProblemSummaryResponse.java b/src/main/java/com/moplus/moplus_server/domain/problemset/dto/response/ProblemSummaryResponse.java new file mode 100644 index 0000000..427ec6c --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problemset/dto/response/ProblemSummaryResponse.java @@ -0,0 +1,31 @@ +package com.moplus.moplus_server.domain.problemset.dto.response; + +import com.moplus.moplus_server.domain.problem.domain.problem.Problem; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import lombok.Builder; + +@Builder +public record ProblemSummaryResponse( + @NotNull(message = "문항 ID는 필수입니다") + Long problemId, + @NotNull(message = "문항 custom ID는 필수입니다") + String problemCustomId, + String problemTitle, + String memo, + String mainProblemImageUrl, + @NotNull(message = "컬렉션 값은 필수입니다.") + List<String> tagNames +) { + public static ProblemSummaryResponse of(Problem problem, List<String> tagNames) { + + return ProblemSummaryResponse.builder() + .problemId(problem.getId()) + .problemCustomId(problem.getProblemCustomId()) + .memo(problem.getMemo()) + .problemTitle(problem.getTitle()) + .mainProblemImageUrl(problem.getMainProblemImageUrl()) + .tagNames(tagNames) + .build(); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problemset/dto/response/ProblemThumbnailResponse.java b/src/main/java/com/moplus/moplus_server/domain/problemset/dto/response/ProblemThumbnailResponse.java new file mode 100644 index 0000000..2cbef8f --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problemset/dto/response/ProblemThumbnailResponse.java @@ -0,0 +1,18 @@ +package com.moplus.moplus_server.domain.problemset.dto.response; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class ProblemThumbnailResponse { + private String problemTitle; + private String problemMemo; + private String mainProblemImageUrl; + + public ProblemThumbnailResponse(String problemTitle, String problemMemo, String mainProblemImageUrl) { + this.problemTitle = problemTitle; + this.problemMemo = problemMemo; + this.mainProblemImageUrl = mainProblemImageUrl; + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problemset/repository/ProblemSetRepository.java b/src/main/java/com/moplus/moplus_server/domain/problemset/repository/ProblemSetRepository.java new file mode 100644 index 0000000..b145fad --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problemset/repository/ProblemSetRepository.java @@ -0,0 +1,22 @@ +package com.moplus.moplus_server.domain.problemset.repository; + +import com.moplus.moplus_server.domain.problemset.domain.ProblemSet; +import com.moplus.moplus_server.domain.problemset.domain.ProblemSetConfirmStatus; +import com.moplus.moplus_server.global.error.exception.ErrorCode; +import com.moplus.moplus_server.global.error.exception.NotFoundException; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProblemSetRepository extends JpaRepository<ProblemSet, Long> { + + default ProblemSet findByIdElseThrow(Long problemSetId) { + return findById(problemSetId).orElseThrow(() -> new NotFoundException(ErrorCode.PROBLEM_SET_NOT_FOUND)); + } + + default void existsConfirmedActiveByIdElseThrow(Long problemSetId) { + if (!existsByIdAndIsDeletedFalseAndConfirmStatus(problemSetId, ProblemSetConfirmStatus.CONFIRMED)) { + throw new NotFoundException(ErrorCode.PROBLEM_SET_NOT_FOUND); + } + } + + boolean existsByIdAndIsDeletedFalseAndConfirmStatus(Long problemSetId, ProblemSetConfirmStatus confirmStatus); +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problemset/repository/ProblemSetSearchRepositoryCustom.java b/src/main/java/com/moplus/moplus_server/domain/problemset/repository/ProblemSetSearchRepositoryCustom.java new file mode 100644 index 0000000..8ebb041 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problemset/repository/ProblemSetSearchRepositoryCustom.java @@ -0,0 +1,82 @@ +package com.moplus.moplus_server.domain.problemset.repository; + +import static com.moplus.moplus_server.domain.problem.domain.problem.QProblem.problem; +import static com.moplus.moplus_server.domain.problemset.domain.ProblemSetConfirmStatus.CONFIRMED; +import static com.moplus.moplus_server.domain.problemset.domain.QProblemSet.problemSet; + +import com.moplus.moplus_server.domain.problemset.dto.response.ProblemSetSearchGetResponse; +import com.moplus.moplus_server.domain.problemset.dto.response.ProblemThumbnailResponse; +import com.querydsl.core.group.GroupBy; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class ProblemSetSearchRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + public List<ProblemSetSearchGetResponse> search(String problemSetTitle, String problemTitle) { + return queryFactory + .from(problemSet) + .leftJoin(problem).on(problem.id.in(problemSet.problemIds)) // 문제 세트 내 포함된 문항과 조인 + .where( + containsProblemSetTitle(problemSetTitle), + containsProblemTitle(problemTitle) + ) + .distinct() + .transform(GroupBy.groupBy(problemSet.id).list( + Projections.constructor(ProblemSetSearchGetResponse.class, + problemSet.id, + problemSet.title.value, + problemSet.confirmStatus, + GroupBy.list( + Projections.constructor(ProblemThumbnailResponse.class, + problem.title.title, + problem.memo, + problem.mainProblemImageUrl + ) + ) + ) + )); + } + + public List<ProblemSetSearchGetResponse> confirmSearch(String problemSetTitle, String problemTitle) { + return queryFactory + .from(problemSet) + .leftJoin(problem).on(problem.id.in(problemSet.problemIds)) // 문제 세트 내 포함된 문항과 조인 + .where( + problemSet.confirmStatus.eq(CONFIRMED), + containsProblemSetTitle(problemSetTitle), + containsProblemTitle(problemTitle) + ) + .distinct() + .transform(GroupBy.groupBy(problemSet.id).list( + Projections.constructor(ProblemSetSearchGetResponse.class, + problemSet.id, + problemSet.title.value, + problemSet.confirmStatus, + GroupBy.list( + Projections.constructor(ProblemThumbnailResponse.class, + problem.title.title, + problem.memo, + problem.mainProblemImageUrl + ) + ) + ) + )); + } + + private BooleanExpression containsProblemSetTitle(String problemSetTitle) { + return (problemSetTitle == null || problemSetTitle.isEmpty()) ? null + : problemSet.title.value.containsIgnoreCase(problemSetTitle); + } + + private BooleanExpression containsProblemTitle(String problemTitle) { + return (problemTitle == null || problemTitle.isEmpty()) ? null : problem.memo.containsIgnoreCase(problemTitle); + } +} \ No newline at end of file diff --git a/src/main/java/com/moplus/moplus_server/domain/problemset/service/ProblemSetDeleteService.java b/src/main/java/com/moplus/moplus_server/domain/problemset/service/ProblemSetDeleteService.java new file mode 100644 index 0000000..830d751 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problemset/service/ProblemSetDeleteService.java @@ -0,0 +1,21 @@ +package com.moplus.moplus_server.domain.problemset.service; + +import com.moplus.moplus_server.domain.problemset.domain.ProblemSet; +import com.moplus.moplus_server.domain.problemset.repository.ProblemSetRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ProblemSetDeleteService { + + private final ProblemSetRepository problemSetRepository; + + @Transactional + public void deleteProblemSet(Long problemSetId) { + ProblemSet problemSet = problemSetRepository.findByIdElseThrow(problemSetId); + problemSet.deleteProblemSet(); + } + +} \ No newline at end of file diff --git a/src/main/java/com/moplus/moplus_server/domain/problemset/service/ProblemSetGetService.java b/src/main/java/com/moplus/moplus_server/domain/problemset/service/ProblemSetGetService.java new file mode 100644 index 0000000..81553a0 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problemset/service/ProblemSetGetService.java @@ -0,0 +1,49 @@ +package com.moplus.moplus_server.domain.problemset.service; + +import com.moplus.moplus_server.domain.concept.domain.ConceptTag; +import com.moplus.moplus_server.domain.concept.repository.ConceptTagRepository; +import com.moplus.moplus_server.domain.problem.domain.problem.Problem; +import com.moplus.moplus_server.domain.problem.repository.PracticeTestTagRepository; +import com.moplus.moplus_server.domain.problem.repository.ProblemRepository; +import com.moplus.moplus_server.domain.problemset.domain.ProblemSet; +import com.moplus.moplus_server.domain.problemset.dto.response.ProblemSetGetResponse; +import com.moplus.moplus_server.domain.problemset.dto.response.ProblemSummaryResponse; +import com.moplus.moplus_server.domain.problemset.repository.ProblemSetRepository; +import com.moplus.moplus_server.domain.publish.domain.Publish; +import com.moplus.moplus_server.domain.publish.repository.PublishRepository; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ProblemSetGetService { + + private final ProblemSetRepository problemSetRepository; + private final ProblemRepository problemRepository; + private final ConceptTagRepository conceptTagRepository; + private final PublishRepository publishRepository; + + @Transactional(readOnly = true) + public ProblemSetGetResponse getProblemSet(Long problemSetId) { + + ProblemSet problemSet = problemSetRepository.findByIdElseThrow(problemSetId); + List<LocalDate> publishedDates = publishRepository.findByProblemSetId(problemSetId).stream() + .map(Publish::getPublishedDate) + .toList(); + + List<ProblemSummaryResponse> problemSummaries = new ArrayList<>(); + for (Long problemId : problemSet.getProblemIds()) { + Problem problem = problemRepository.findByIdElseThrow(problemId); + List<String> tagNames = conceptTagRepository.findAllByIdsElseThrow(problem.getConceptTagIds()) + .stream() + .map(ConceptTag::getName) + .toList(); + problemSummaries.add(ProblemSummaryResponse.of(problem, tagNames)); + } + return ProblemSetGetResponse.of(problemSet, publishedDates, problemSummaries); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problemset/service/ProblemSetSaveService.java b/src/main/java/com/moplus/moplus_server/domain/problemset/service/ProblemSetSaveService.java new file mode 100644 index 0000000..2281c09 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problemset/service/ProblemSetSaveService.java @@ -0,0 +1,26 @@ +package com.moplus.moplus_server.domain.problemset.service; + +import com.moplus.moplus_server.domain.problem.repository.ProblemRepository; +import com.moplus.moplus_server.domain.problemset.domain.ProblemSet; +import com.moplus.moplus_server.domain.problemset.repository.ProblemSetRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ProblemSetSaveService { + + private final ProblemSetRepository problemSetRepository; + private final ProblemRepository problemRepository; + + @Transactional + public Long createProblemSet() { + + // ProblemSet 생성 + ProblemSet problemSet = ProblemSet.ofEmptyProblemSet(); + + return problemSetRepository.save(problemSet).getId(); + } + +} diff --git a/src/main/java/com/moplus/moplus_server/domain/problemset/service/ProblemSetUpdateService.java b/src/main/java/com/moplus/moplus_server/domain/problemset/service/ProblemSetUpdateService.java new file mode 100644 index 0000000..5e01a9c --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/problemset/service/ProblemSetUpdateService.java @@ -0,0 +1,65 @@ +package com.moplus.moplus_server.domain.problemset.service; + +import com.moplus.moplus_server.domain.problem.domain.problem.Problem; +import com.moplus.moplus_server.domain.problem.repository.ProblemRepository; +import com.moplus.moplus_server.domain.problemset.domain.ProblemSet; +import com.moplus.moplus_server.domain.problemset.domain.ProblemSetConfirmStatus; +import com.moplus.moplus_server.domain.problemset.dto.request.ProblemReorderRequest; +import com.moplus.moplus_server.domain.problemset.dto.request.ProblemSetUpdateRequest; +import com.moplus.moplus_server.domain.problemset.repository.ProblemSetRepository; +import com.moplus.moplus_server.domain.publish.domain.Publish; +import com.moplus.moplus_server.domain.publish.repository.PublishRepository; +import com.moplus.moplus_server.global.error.exception.ErrorCode; +import com.moplus.moplus_server.global.error.exception.InvalidValueException; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ProblemSetUpdateService { + + private final ProblemSetRepository problemSetRepository; + private final ProblemRepository problemRepository; + private final PublishRepository publishRepository; + + @Transactional + public void reorderProblems(Long problemSetId, ProblemReorderRequest request) { + ProblemSet problemSet = problemSetRepository.findByIdElseThrow(problemSetId); + + problemSet.updateProblemOrder(request.newProblems()); + } + + @Transactional + public void updateProblemSet(Long problemSetId, ProblemSetUpdateRequest request) { + ProblemSet problemSet = problemSetRepository.findByIdElseThrow(problemSetId); + + // 빈 문항 유효성 검증 + if (request.problemIds().isEmpty()) { + throw new InvalidValueException(ErrorCode.EMPTY_PROBLEMS_ERROR); + } + + request.problemIds().forEach(problemRepository::findByIdElseThrow); + + problemSet.updateProblemSet(request.problemSetTitle(), request.problemIds()); + } + + @Transactional + public ProblemSetConfirmStatus toggleConfirmProblemSet(Long problemSetId) { + ProblemSet problemSet = problemSetRepository.findByIdElseThrow(problemSetId); + List<Publish> publishes = publishRepository.findByProblemSetId(problemSetId); + if (!publishes.isEmpty()) { + throw new InvalidValueException(ErrorCode.ALREADY_PUBLISHED_ERROR); + } + + List<Problem> problems = new ArrayList<>(); + for (Long problemId : problemSet.getProblemIds()) { + Problem problem = problemRepository.findByIdElseThrow(problemId); + problems.add(problem); + } + problemSet.toggleConfirm(problems); + return problemSet.getConfirmStatus(); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/publish/controller/PublishController.java b/src/main/java/com/moplus/moplus_server/domain/publish/controller/PublishController.java new file mode 100644 index 0000000..7a53f8d --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/publish/controller/PublishController.java @@ -0,0 +1,58 @@ +package com.moplus.moplus_server.domain.publish.controller; + +import com.moplus.moplus_server.domain.publish.dto.request.PublishPostRequest; +import com.moplus.moplus_server.domain.publish.dto.response.PublishMonthGetResponse; +import com.moplus.moplus_server.domain.publish.service.PublishDeleteService; +import com.moplus.moplus_server.domain.publish.service.PublishGetService; +import com.moplus.moplus_server.domain.publish.service.PublishSaveService; +import com.moplus.moplus_server.global.response.IdResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "발행", description = "발행 관련 API") +@RestController +@RequestMapping("/api/v1/publish") +@RequiredArgsConstructor +public class PublishController { + + private final PublishGetService publishGetService; + private final PublishSaveService publishSaveService; + private final PublishDeleteService publishDeleteService; + + @GetMapping("/{year}/{month}") + @Operation(summary = "연월별 발행 조회", description = "연월별로 발행된 세트들을 조회합니다.") + public ResponseEntity<List<PublishMonthGetResponse>> getPublishMonth( + @PathVariable int year, + @PathVariable int month + ) { + return ResponseEntity.ok(publishGetService.getPublishMonth(year, month)); + } + + @PostMapping("") + @Operation(summary = "발행 생성하기", description = "특정 날짜에 문항세트를 발행합니다.") + public ResponseEntity<IdResponse> postPublish( + @Valid @RequestBody PublishPostRequest request + ) { + return ResponseEntity.ok(new IdResponse(publishSaveService.createPublish(request))); + } + + @DeleteMapping("/{publishId}") + @Operation(summary = "발행 삭제", description = "발행을 삭제합니다.") + public ResponseEntity<Void> deleteProblemSet( + @PathVariable Long publishId + ) { + publishDeleteService.deletePublish(publishId); + return ResponseEntity.ok(null); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/publish/domain/Publish.java b/src/main/java/com/moplus/moplus_server/domain/publish/domain/Publish.java new file mode 100644 index 0000000..3ea566c --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/publish/domain/Publish.java @@ -0,0 +1,51 @@ +package com.moplus.moplus_server.domain.publish.domain; + +import com.moplus.moplus_server.global.common.BaseEntity; +import com.moplus.moplus_server.global.error.exception.ErrorCode; +import com.moplus.moplus_server.global.error.exception.InvalidValueException; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import java.time.LocalDate; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Publish extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "publish_id") + private Long id; + + @Column(nullable = false) + private LocalDate publishedDate; + + @Column(name = "problem_set_id", nullable = false) + private Long problemSetId; + + @Builder + public Publish(LocalDate publishedDate, Long problemSetId) { + this.publishedDate = publishedDate; + this.problemSetId = problemSetId; + } + + public void validatePublishedDate() { + // 발행 시점 다음날부터 발행 가능 + if (this.publishedDate.isBefore(LocalDate.now().plusDays(1))) { + throw new InvalidValueException(ErrorCode.INVALID_DATE_ERROR); + } + } + + public void validateDeletable() { + if (this.publishedDate.isBefore(LocalDate.now())) { + throw new InvalidValueException(ErrorCode.CANNOT_DELETE_PAST_PUBLISH); + } + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/publish/dto/request/PublishPostRequest.java b/src/main/java/com/moplus/moplus_server/domain/publish/dto/request/PublishPostRequest.java new file mode 100644 index 0000000..7d3960e --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/publish/dto/request/PublishPostRequest.java @@ -0,0 +1,19 @@ +package com.moplus.moplus_server.domain.publish.dto.request; + +import com.moplus.moplus_server.domain.publish.domain.Publish; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; + +public record PublishPostRequest( + @NotNull + LocalDate publishedDate, + @NotNull + Long problemSetId +) { + public Publish toEntity() { + return Publish.builder() + .publishedDate(this.publishedDate) + .problemSetId(this.problemSetId) + .build(); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/publish/dto/response/PublishMonthGetResponse.java b/src/main/java/com/moplus/moplus_server/domain/publish/dto/response/PublishMonthGetResponse.java new file mode 100644 index 0000000..8ca3189 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/publish/dto/response/PublishMonthGetResponse.java @@ -0,0 +1,19 @@ +package com.moplus.moplus_server.domain.publish.dto.response; + +import com.moplus.moplus_server.domain.publish.domain.Publish; +import lombok.Builder; + +@Builder +public record PublishMonthGetResponse( + Long publishId, + int day, + PublishProblemSetResponse problemSetInfo +) { + public static PublishMonthGetResponse of(Publish publish, PublishProblemSetResponse problemSetInfos) { + return PublishMonthGetResponse.builder() + .publishId(publish.getId()) + .day(publish.getPublishedDate().getDayOfMonth()) + .problemSetInfo(problemSetInfos) + .build(); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/publish/dto/response/PublishProblemSetResponse.java b/src/main/java/com/moplus/moplus_server/domain/publish/dto/response/PublishProblemSetResponse.java new file mode 100644 index 0000000..1afc053 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/publish/dto/response/PublishProblemSetResponse.java @@ -0,0 +1,18 @@ +package com.moplus.moplus_server.domain.publish.dto.response; + +import com.moplus.moplus_server.domain.problemset.domain.ProblemSet; +import lombok.Builder; + +@Builder +public record PublishProblemSetResponse( + Long id, + String title +) { + public static PublishProblemSetResponse of(ProblemSet problemSet) { + + return PublishProblemSetResponse.builder() + .id(problemSet.getId()) + .title(problemSet.getTitle().getValue()) + .build(); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/publish/repository/PublishRepository.java b/src/main/java/com/moplus/moplus_server/domain/publish/repository/PublishRepository.java new file mode 100644 index 0000000..9168947 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/publish/repository/PublishRepository.java @@ -0,0 +1,18 @@ +package com.moplus.moplus_server.domain.publish.repository; + +import com.moplus.moplus_server.domain.publish.domain.Publish; +import com.moplus.moplus_server.global.error.exception.ErrorCode; +import com.moplus.moplus_server.global.error.exception.NotFoundException; +import java.time.LocalDate; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PublishRepository extends JpaRepository<Publish, Long> { + List<Publish> findByPublishedDateBetween(LocalDate startDate, LocalDate endDate); + + default Publish findByIdElseThrow(Long publishId) { + return findById(publishId).orElseThrow(() -> new NotFoundException(ErrorCode.PUBLISH_NOT_FOUND)); + } + + List<Publish> findByProblemSetId(Long problemSetId); +} diff --git a/src/main/java/com/moplus/moplus_server/domain/publish/service/PublishDeleteService.java b/src/main/java/com/moplus/moplus_server/domain/publish/service/PublishDeleteService.java new file mode 100644 index 0000000..039ac00 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/publish/service/PublishDeleteService.java @@ -0,0 +1,22 @@ +package com.moplus.moplus_server.domain.publish.service; + +import com.moplus.moplus_server.domain.publish.domain.Publish; +import com.moplus.moplus_server.domain.publish.repository.PublishRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PublishDeleteService { + + private final PublishRepository publishRepository; + + @Transactional + public void deletePublish(Long publishId) { + Publish publish = publishRepository.findByIdElseThrow(publishId); + publish.validateDeletable(); + + publishRepository.delete(publish); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/publish/service/PublishGetService.java b/src/main/java/com/moplus/moplus_server/domain/publish/service/PublishGetService.java new file mode 100644 index 0000000..bca2511 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/publish/service/PublishGetService.java @@ -0,0 +1,65 @@ +package com.moplus.moplus_server.domain.publish.service; + +import com.moplus.moplus_server.domain.problemset.domain.ProblemSet; +import com.moplus.moplus_server.domain.problemset.repository.ProblemSetRepository; +import com.moplus.moplus_server.domain.publish.domain.Publish; +import com.moplus.moplus_server.domain.publish.dto.response.PublishMonthGetResponse; +import com.moplus.moplus_server.domain.publish.dto.response.PublishProblemSetResponse; +import com.moplus.moplus_server.domain.publish.repository.PublishRepository; +import com.moplus.moplus_server.global.error.exception.ErrorCode; +import com.moplus.moplus_server.global.error.exception.InvalidValueException; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PublishGetService { + + private final PublishRepository publishRepository; + private final ProblemSetRepository problemSetRepository; + + @Transactional(readOnly = true) + public List<PublishMonthGetResponse> getPublishMonth(int year, int month) { + if (month < 1 || month > 12) { + throw new InvalidValueException(ErrorCode.INVALID_MONTH_ERROR); + } + LocalDate startDate = LocalDate.of(year, month, 1); + LocalDate endDate = startDate.withDayOfMonth(startDate.lengthOfMonth()); + + // 주어진 월에 해당하는 모든 Publish 조회 + List<Publish> publishes = publishRepository.findByPublishedDateBetween(startDate, endDate); + + // 한 번의 쿼리로 모든 ProblemSet 조회 + Map<Long, ProblemSet> problemSetMap = getProblemSetMap(publishes); + + return publishes.stream() + .map(publish -> convertToResponse(publish, problemSetMap)) + .collect(Collectors.toList()); + } + + private Map<Long, ProblemSet> getProblemSetMap(List<Publish> publishes) { + List<Long> problemSetIds = publishes.stream() + .map(Publish::getProblemSetId) + .distinct() + .collect(Collectors.toList()); + + return problemSetRepository.findAllById(problemSetIds).stream() + .collect(Collectors.toMap(ProblemSet::getId, problemSet -> problemSet)); + } + + private PublishMonthGetResponse convertToResponse(Publish publish, Map<Long, ProblemSet> problemSetMap) { + ProblemSet problemSet = problemSetMap.get(publish.getProblemSetId()); + if (problemSet == null) { + throw new InvalidValueException(ErrorCode.PROBLEM_SET_NOT_FOUND); + } + return PublishMonthGetResponse.of( + publish, + PublishProblemSetResponse.of(problemSet) + ); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/publish/service/PublishSaveService.java b/src/main/java/com/moplus/moplus_server/domain/publish/service/PublishSaveService.java new file mode 100644 index 0000000..d134080 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/publish/service/PublishSaveService.java @@ -0,0 +1,26 @@ +package com.moplus.moplus_server.domain.publish.service; + +import com.moplus.moplus_server.domain.problemset.repository.ProblemSetRepository; +import com.moplus.moplus_server.domain.publish.domain.Publish; +import com.moplus.moplus_server.domain.publish.dto.request.PublishPostRequest; +import com.moplus.moplus_server.domain.publish.repository.PublishRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PublishSaveService { + + private final ProblemSetRepository problemSetRepository; + private final PublishRepository publishRepository; + + @Transactional + public Long createPublish(PublishPostRequest request) { + problemSetRepository.existsConfirmedActiveByIdElseThrow(request.problemSetId()); + Publish publish = request.toEntity(); + // 발행날짜 유효성 검사 + publish.validatePublishedDate(); + return publishRepository.save(publish).getId(); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/DetailResultApplication/controller/DetailResultApplicationController.java b/src/main/java/com/moplus/moplus_server/domain/v0/DetailResultApplication/controller/DetailResultApplicationController.java similarity index 65% rename from src/main/java/com/moplus/moplus_server/domain/DetailResultApplication/controller/DetailResultApplicationController.java rename to src/main/java/com/moplus/moplus_server/domain/v0/DetailResultApplication/controller/DetailResultApplicationController.java index e497da5..a7edd83 100644 --- a/src/main/java/com/moplus/moplus_server/domain/DetailResultApplication/controller/DetailResultApplicationController.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/DetailResultApplication/controller/DetailResultApplicationController.java @@ -1,8 +1,9 @@ -package com.moplus.moplus_server.domain.DetailResultApplication.controller; +package com.moplus.moplus_server.domain.v0.DetailResultApplication.controller; -import com.moplus.moplus_server.domain.DetailResultApplication.dto.request.DetailResultApplicationPostRequest; -import com.moplus.moplus_server.domain.DetailResultApplication.dto.response.ReviewNoteGetResponse; -import com.moplus.moplus_server.domain.DetailResultApplication.service.DetailResultApplicationService; +import com.moplus.moplus_server.domain.v0.DetailResultApplication.dto.request.DetailResultApplicationPostRequest; +import com.moplus.moplus_server.domain.v0.DetailResultApplication.dto.response.ReviewNoteGetResponse; +import com.moplus.moplus_server.domain.v0.DetailResultApplication.service.DetailResultApplicationService; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -11,6 +12,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Hidden @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/detailResultApplication") @@ -20,7 +22,8 @@ public class DetailResultApplicationController { @PostMapping("") @Operation(summary = "모의고사 결과 상세 분석서 신청하기") - public ResponseEntity<ReviewNoteGetResponse> createApplication(@RequestBody DetailResultApplicationPostRequest request) { + public ResponseEntity<ReviewNoteGetResponse> createApplication( + @RequestBody DetailResultApplicationPostRequest request) { detailResultApplicationService.saveApplication(request); ReviewNoteGetResponse reviewNoteInfo = detailResultApplicationService.getReviewNoteInfo(request.testResultId()); return ResponseEntity.ok(reviewNoteInfo); diff --git a/src/main/java/com/moplus/moplus_server/domain/v0/DetailResultApplication/dto/request/DetailResultApplicationPostRequest.java b/src/main/java/com/moplus/moplus_server/domain/v0/DetailResultApplication/dto/request/DetailResultApplicationPostRequest.java new file mode 100644 index 0000000..2440e76 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/v0/DetailResultApplication/dto/request/DetailResultApplicationPostRequest.java @@ -0,0 +1,18 @@ +package com.moplus.moplus_server.domain.v0.DetailResultApplication.dto.request; + +import com.moplus.moplus_server.domain.v0.DetailResultApplication.entity.DetailResultApplication; + +public record DetailResultApplicationPostRequest( + Long testResultId, + String name, + String phoneNumber +) { + + public DetailResultApplication toEntity() { + return DetailResultApplication.builder() + .testResultId(testResultId) + .name(name) + .phoneNumber(phoneNumber) + .build(); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/v0/DetailResultApplication/dto/response/ProblemGetResponse.java b/src/main/java/com/moplus/moplus_server/domain/v0/DetailResultApplication/dto/response/ProblemGetResponse.java new file mode 100644 index 0000000..7fb14eb --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/v0/DetailResultApplication/dto/response/ProblemGetResponse.java @@ -0,0 +1,30 @@ +package com.moplus.moplus_server.domain.v0.DetailResultApplication.dto.response; + +import com.moplus.moplus_server.domain.v0.practiceTest.domain.ProblemForTest; +import com.moplus.moplus_server.domain.v0.practiceTest.domain.ProblemImageForTest; +import com.moplus.moplus_server.domain.v0.practiceTest.domain.ProblemRating; +import lombok.Builder; + +@Builder +public record ProblemGetResponse( + String problemNumber, + String difficultLevel, + double correctRate, + String rating, + String imageUrl +) { + + public static ProblemGetResponse of( + ProblemForTest problemForTest + ) { + ProblemRating problemRating = problemForTest.getProblemRating(); + ProblemImageForTest image = problemForTest.getImage(); + return ProblemGetResponse.builder() + .problemNumber(problemForTest.getProblemNumber()) + .difficultLevel(problemRating.getDifficultyLevel()) + .correctRate(problemForTest.getCorrectRate()) + .rating(problemRating.getRating()) + .imageUrl(image.getImageUrl()) + .build(); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/DetailResultApplication/dto/response/ReviewNoteGetResponse.java b/src/main/java/com/moplus/moplus_server/domain/v0/DetailResultApplication/dto/response/ReviewNoteGetResponse.java similarity index 75% rename from src/main/java/com/moplus/moplus_server/domain/DetailResultApplication/dto/response/ReviewNoteGetResponse.java rename to src/main/java/com/moplus/moplus_server/domain/v0/DetailResultApplication/dto/response/ReviewNoteGetResponse.java index 95c8674..9fe3e97 100644 --- a/src/main/java/com/moplus/moplus_server/domain/DetailResultApplication/dto/response/ReviewNoteGetResponse.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/DetailResultApplication/dto/response/ReviewNoteGetResponse.java @@ -1,10 +1,8 @@ -package com.moplus.moplus_server.domain.DetailResultApplication.dto.response; +package com.moplus.moplus_server.domain.v0.DetailResultApplication.dto.response; -import com.moplus.moplus_server.domain.TestResult.dto.response.EstimatedRatingGetResponse; -import com.moplus.moplus_server.domain.TestResult.dto.response.IncorrectProblemGetResponse; -import com.moplus.moplus_server.domain.TestResult.dto.response.RatingTableGetResponse; -import com.moplus.moplus_server.domain.TestResult.dto.response.TestResultGetResponse; -import com.moplus.moplus_server.domain.TestResult.entity.TestResult; +import com.moplus.moplus_server.domain.v0.TestResult.dto.response.EstimatedRatingGetResponse; +import com.moplus.moplus_server.domain.v0.TestResult.dto.response.IncorrectProblemGetResponse; +import com.moplus.moplus_server.domain.v0.TestResult.entity.TestResult; import java.time.Duration; import java.util.List; import lombok.Builder; diff --git a/src/main/java/com/moplus/moplus_server/domain/DetailResultApplication/entity/DetailResultApplication.java b/src/main/java/com/moplus/moplus_server/domain/v0/DetailResultApplication/entity/DetailResultApplication.java similarity index 92% rename from src/main/java/com/moplus/moplus_server/domain/DetailResultApplication/entity/DetailResultApplication.java rename to src/main/java/com/moplus/moplus_server/domain/v0/DetailResultApplication/entity/DetailResultApplication.java index 0f9df6a..0b35102 100644 --- a/src/main/java/com/moplus/moplus_server/domain/DetailResultApplication/entity/DetailResultApplication.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/DetailResultApplication/entity/DetailResultApplication.java @@ -1,4 +1,4 @@ -package com.moplus.moplus_server.domain.DetailResultApplication.entity; +package com.moplus.moplus_server.domain.v0.DetailResultApplication.entity; import com.moplus.moplus_server.global.common.BaseEntity; import jakarta.persistence.Column; diff --git a/src/main/java/com/moplus/moplus_server/domain/v0/DetailResultApplication/respository/DetailResultApplicationRepository.java b/src/main/java/com/moplus/moplus_server/domain/v0/DetailResultApplication/respository/DetailResultApplicationRepository.java new file mode 100644 index 0000000..b723fc9 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/v0/DetailResultApplication/respository/DetailResultApplicationRepository.java @@ -0,0 +1,8 @@ +package com.moplus.moplus_server.domain.v0.DetailResultApplication.respository; + +import com.moplus.moplus_server.domain.v0.DetailResultApplication.entity.DetailResultApplication; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface DetailResultApplicationRepository extends JpaRepository<DetailResultApplication, Long> { + +} diff --git a/src/main/java/com/moplus/moplus_server/domain/DetailResultApplication/service/DetailResultApplicationService.java b/src/main/java/com/moplus/moplus_server/domain/v0/DetailResultApplication/service/DetailResultApplicationService.java similarity index 61% rename from src/main/java/com/moplus/moplus_server/domain/DetailResultApplication/service/DetailResultApplicationService.java rename to src/main/java/com/moplus/moplus_server/domain/v0/DetailResultApplication/service/DetailResultApplicationService.java index 9765fe3..1382c1f 100644 --- a/src/main/java/com/moplus/moplus_server/domain/DetailResultApplication/service/DetailResultApplicationService.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/DetailResultApplication/service/DetailResultApplicationService.java @@ -1,20 +1,20 @@ -package com.moplus.moplus_server.domain.DetailResultApplication.service; +package com.moplus.moplus_server.domain.v0.DetailResultApplication.service; -import com.moplus.moplus_server.domain.DetailResultApplication.dto.request.DetailResultApplicationPostRequest; -import com.moplus.moplus_server.domain.DetailResultApplication.dto.response.ProblemGetResponse; -import com.moplus.moplus_server.domain.DetailResultApplication.dto.response.ReviewNoteGetResponse; -import com.moplus.moplus_server.domain.DetailResultApplication.respository.DetailResultApplicationRepository; -import com.moplus.moplus_server.domain.TestResult.dto.response.EstimatedRatingGetResponse; -import com.moplus.moplus_server.domain.TestResult.entity.IncorrectProblem; -import com.moplus.moplus_server.domain.TestResult.entity.TestResult; -import com.moplus.moplus_server.domain.TestResult.repository.EstimatedRatingRepository; -import com.moplus.moplus_server.domain.TestResult.repository.IncorrectProblemRepository; -import com.moplus.moplus_server.domain.TestResult.repository.TestResultRepository; -import com.moplus.moplus_server.domain.TestResult.service.IncorrectProblemService; -import com.moplus.moplus_server.domain.practiceTest.domain.PracticeTest; -import com.moplus.moplus_server.domain.practiceTest.domain.Problem; -import com.moplus.moplus_server.domain.practiceTest.repository.PracticeTestRepository; -import com.moplus.moplus_server.domain.practiceTest.repository.ProblemRepository; +import com.moplus.moplus_server.domain.v0.DetailResultApplication.dto.request.DetailResultApplicationPostRequest; +import com.moplus.moplus_server.domain.v0.DetailResultApplication.dto.response.ProblemGetResponse; +import com.moplus.moplus_server.domain.v0.DetailResultApplication.dto.response.ReviewNoteGetResponse; +import com.moplus.moplus_server.domain.v0.DetailResultApplication.respository.DetailResultApplicationRepository; +import com.moplus.moplus_server.domain.v0.TestResult.dto.response.EstimatedRatingGetResponse; +import com.moplus.moplus_server.domain.v0.TestResult.entity.IncorrectProblem; +import com.moplus.moplus_server.domain.v0.TestResult.entity.TestResult; +import com.moplus.moplus_server.domain.v0.TestResult.repository.EstimatedRatingRepository; +import com.moplus.moplus_server.domain.v0.TestResult.repository.IncorrectProblemRepository; +import com.moplus.moplus_server.domain.v0.TestResult.repository.TestResultRepository; +import com.moplus.moplus_server.domain.v0.TestResult.service.IncorrectProblemService; +import com.moplus.moplus_server.domain.v0.practiceTest.domain.PracticeTest; +import com.moplus.moplus_server.domain.v0.practiceTest.domain.ProblemForTest; +import com.moplus.moplus_server.domain.v0.practiceTest.repository.PracticeTestRepository; +import com.moplus.moplus_server.domain.v0.practiceTest.repository.ProblemForTestRepository; import com.moplus.moplus_server.global.error.exception.ErrorCode; import com.moplus.moplus_server.global.error.exception.NotFoundException; import java.time.Duration; @@ -33,7 +33,7 @@ public class DetailResultApplicationService { private final EstimatedRatingRepository estimatedRatingRepository; private final IncorrectProblemService incorrectProblemService; private final IncorrectProblemRepository incorrectProblemRepository; - private final ProblemRepository problemRepository; + private final ProblemForTestRepository problemForTestRepository; @Transactional public void saveApplication(DetailResultApplicationPostRequest request) { @@ -47,28 +47,30 @@ public ReviewNoteGetResponse getReviewNoteInfo(Long testResultId) { PracticeTest practiceTest = getPracticeTestById(testResult.getPracticeTestId()); Duration averageSolvingTime = practiceTest.getAverageSolvingTime(); - List<EstimatedRatingGetResponse> estimatedRatingGetResponses = estimatedRatingRepository.findAllByTestResultId(testResultId).stream() + List<EstimatedRatingGetResponse> estimatedRatingGetResponses = estimatedRatingRepository.findAllByTestResultId( + testResultId).stream() .map(EstimatedRatingGetResponse::from) .toList(); int 대성마이맥_rating = estimatedRatingGetResponses.get(0).estimatedRating(); - List<Problem> incorrectProblems = incorrectProblemRepository.findAllByTestResultId(testResultId).stream() + List<ProblemForTest> incorrectProblemForTests = incorrectProblemRepository.findAllByTestResultId(testResultId) + .stream() .map(IncorrectProblem::getProblemId) - .map(problemId -> problemRepository.findById(problemId).orElseThrow()) + .map(problemId -> problemForTestRepository.findById(problemId).orElseThrow()) .toList(); - List<ProblemGetResponse> forCurrentRating = incorrectProblems.stream() + List<ProblemGetResponse> forCurrentRating = incorrectProblemForTests.stream() .filter(problem -> problem.getProblemRating().getRatingValue() == 대성마이맥_rating) .map(ProblemGetResponse::of) .toList(); - List<ProblemGetResponse> forNextRating = incorrectProblems.stream() + List<ProblemGetResponse> forNextRating = incorrectProblemForTests.stream() .filter(problem -> problem.getProblemRating().getRatingValue() == 대성마이맥_rating - 1) .map(ProblemGetResponse::of) .toList(); - List<ProblemGetResponse> forBeforeRating = incorrectProblems.stream() + List<ProblemGetResponse> forBeforeRating = incorrectProblemForTests.stream() .filter(problem -> problem.getProblemRating().getRatingValue() >= 대성마이맥_rating + 1) .map(ProblemGetResponse::of) .toList(); diff --git a/src/main/java/com/moplus/moplus_server/domain/TestResult/controller/RatingController.java b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/controller/RatingController.java similarity index 76% rename from src/main/java/com/moplus/moplus_server/domain/TestResult/controller/RatingController.java rename to src/main/java/com/moplus/moplus_server/domain/v0/TestResult/controller/RatingController.java index 3a89f6e..d95bd4c 100644 --- a/src/main/java/com/moplus/moplus_server/domain/TestResult/controller/RatingController.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/controller/RatingController.java @@ -1,6 +1,7 @@ -package com.moplus.moplus_server.domain.TestResult.controller; +package com.moplus.moplus_server.domain.v0.TestResult.controller; -import com.moplus.moplus_server.domain.TestResult.dto.response.RatingGetResponse; +import com.moplus.moplus_server.domain.v0.TestResult.dto.response.RatingGetResponse; +import io.swagger.v3.oas.annotations.Hidden; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -9,6 +10,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Hidden @RestController @RequestMapping("/api/v1/rating") @RequiredArgsConstructor diff --git a/src/main/java/com/moplus/moplus_server/domain/TestResult/controller/TestResultController.java b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/controller/TestResultController.java similarity index 65% rename from src/main/java/com/moplus/moplus_server/domain/TestResult/controller/TestResultController.java rename to src/main/java/com/moplus/moplus_server/domain/v0/TestResult/controller/TestResultController.java index 6e4443e..4c0ae4b 100644 --- a/src/main/java/com/moplus/moplus_server/domain/TestResult/controller/TestResultController.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/controller/TestResultController.java @@ -1,9 +1,10 @@ -package com.moplus.moplus_server.domain.TestResult.controller; +package com.moplus.moplus_server.domain.v0.TestResult.controller; -import com.moplus.moplus_server.domain.TestResult.dto.request.IncorrectProblemPostRequest; -import com.moplus.moplus_server.domain.TestResult.dto.request.SolvingTimePostRequest; -import com.moplus.moplus_server.domain.TestResult.dto.response.TestResultGetResponse; -import com.moplus.moplus_server.domain.TestResult.service.TestResultService; +import com.moplus.moplus_server.domain.v0.TestResult.dto.request.IncorrectProblemPostRequest; +import com.moplus.moplus_server.domain.v0.TestResult.dto.request.SolvingTimePostRequest; +import com.moplus.moplus_server.domain.v0.TestResult.dto.response.TestResultGetResponse; +import com.moplus.moplus_server.domain.v0.TestResult.service.TestResultService; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import java.util.List; import lombok.RequiredArgsConstructor; @@ -15,6 +16,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Hidden @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/testResult") @@ -25,7 +27,7 @@ public class TestResultController { @PostMapping("/{practiceTestId}/uploadingAnswer") @Operation(summary = "답 입력 결과 제출", description = "테스트 결과지의 ID를 반환합니다.") public ResponseEntity<Long> uploadTestAnswers(@PathVariable("practiceTestId") Long id, - @RequestBody List<IncorrectProblemPostRequest> requests) { + @RequestBody List<IncorrectProblemPostRequest> requests) { return ResponseEntity.ok(testResultService.createTestResult(id, requests)); } @@ -37,11 +39,11 @@ public ResponseEntity<TestResultGetResponse> getTestAnswers(@PathVariable("testR @PostMapping("/{testResultId}/uploadingMinute") @Operation(summary = "풀이 시간 제출 및 시험 결과지 받기", - description = "성적과 풀이시간에 기반한 내 위치 결과지를 반환합니다. 풀이시간은 'PT{시간}H{분}M' 형식으로 보내주세요") + description = "성적과 풀이시간에 기반한 내 위치 결과지를 반환합니다. 풀이시간은 'PT{시간}H{분}M' 형식으로 보내주세요") public ResponseEntity<TestResultGetResponse> uploadSolvingMinute( - @PathVariable("testResultId") Long id, - @RequestBody SolvingTimePostRequest request - ) { + @PathVariable("testResultId") Long id, + @RequestBody SolvingTimePostRequest request + ) { return ResponseEntity.ok(testResultService.getTestResultBySolvingTime(id, request)); } } diff --git a/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/dto/request/IncorrectProblemPostRequest.java b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/dto/request/IncorrectProblemPostRequest.java new file mode 100644 index 0000000..6a97f81 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/dto/request/IncorrectProblemPostRequest.java @@ -0,0 +1,19 @@ +package com.moplus.moplus_server.domain.v0.TestResult.dto.request; + +import com.moplus.moplus_server.domain.v0.TestResult.entity.IncorrectProblem; +import com.moplus.moplus_server.domain.v0.practiceTest.domain.ProblemForTest; + +public record IncorrectProblemPostRequest( + String problemNumber, + String incorrectAnswer +) { + + public IncorrectProblem toEntity(ProblemForTest problemForTest) { + return IncorrectProblem.builder() + .problemNumber(problemNumber) + .incorrectAnswer(incorrectAnswer) + .point(problemForTest.getPoint()) + .problemId(problemForTest.getId()) + .build(); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/TestResult/dto/request/SolvingTimePostRequest.java b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/dto/request/SolvingTimePostRequest.java similarity index 53% rename from src/main/java/com/moplus/moplus_server/domain/TestResult/dto/request/SolvingTimePostRequest.java rename to src/main/java/com/moplus/moplus_server/domain/v0/TestResult/dto/request/SolvingTimePostRequest.java index 3ef3a47..e10404d 100644 --- a/src/main/java/com/moplus/moplus_server/domain/TestResult/dto/request/SolvingTimePostRequest.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/dto/request/SolvingTimePostRequest.java @@ -1,12 +1,12 @@ -package com.moplus.moplus_server.domain.TestResult.dto.request; +package com.moplus.moplus_server.domain.v0.TestResult.dto.request; import io.swagger.v3.oas.annotations.media.Schema; import java.time.Duration; import java.time.LocalTime; public record SolvingTimePostRequest( - @Schema(example = "PT1H10M") - String solvingTime + @Schema(example = "PT1H10M") + String solvingTime ) { } diff --git a/src/main/java/com/moplus/moplus_server/domain/TestResult/dto/response/EstimatedRatingGetResponse.java b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/dto/response/EstimatedRatingGetResponse.java similarity index 69% rename from src/main/java/com/moplus/moplus_server/domain/TestResult/dto/response/EstimatedRatingGetResponse.java rename to src/main/java/com/moplus/moplus_server/domain/v0/TestResult/dto/response/EstimatedRatingGetResponse.java index c202404..d7ac54c 100644 --- a/src/main/java/com/moplus/moplus_server/domain/TestResult/dto/response/EstimatedRatingGetResponse.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/dto/response/EstimatedRatingGetResponse.java @@ -1,6 +1,6 @@ -package com.moplus.moplus_server.domain.TestResult.dto.response; +package com.moplus.moplus_server.domain.v0.TestResult.dto.response; -import com.moplus.moplus_server.domain.TestResult.entity.EstimatedRating; +import com.moplus.moplus_server.domain.v0.TestResult.entity.EstimatedRating; public record EstimatedRatingGetResponse( String ratingProvider, diff --git a/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/dto/response/IncorrectProblemGetResponse.java b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/dto/response/IncorrectProblemGetResponse.java new file mode 100644 index 0000000..c34c9c4 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/dto/response/IncorrectProblemGetResponse.java @@ -0,0 +1,19 @@ +package com.moplus.moplus_server.domain.v0.TestResult.dto.response; + +import com.moplus.moplus_server.domain.v0.TestResult.entity.IncorrectProblem; +import lombok.Builder; + +@Builder +public record IncorrectProblemGetResponse( + String problemNumber, + double correctRate +) { + + public static IncorrectProblemGetResponse from(IncorrectProblem incorrectProblem) { + return IncorrectProblemGetResponse.builder() + .problemNumber(incorrectProblem.getProblemNumber()) + .correctRate(incorrectProblem.getCorrectRate()) + .build(); + } + +} diff --git a/src/main/java/com/moplus/moplus_server/domain/TestResult/dto/response/RatingDetailsResponse.java b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/dto/response/RatingDetailsResponse.java similarity index 86% rename from src/main/java/com/moplus/moplus_server/domain/TestResult/dto/response/RatingDetailsResponse.java rename to src/main/java/com/moplus/moplus_server/domain/v0/TestResult/dto/response/RatingDetailsResponse.java index 0ad15bd..5565f29 100644 --- a/src/main/java/com/moplus/moplus_server/domain/TestResult/dto/response/RatingDetailsResponse.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/dto/response/RatingDetailsResponse.java @@ -1,4 +1,4 @@ -package com.moplus.moplus_server.domain.TestResult.dto.response; +package com.moplus.moplus_server.domain.v0.TestResult.dto.response; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/src/main/java/com/moplus/moplus_server/domain/TestResult/dto/response/RatingGetResponse.java b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/dto/response/RatingGetResponse.java similarity index 79% rename from src/main/java/com/moplus/moplus_server/domain/TestResult/dto/response/RatingGetResponse.java rename to src/main/java/com/moplus/moplus_server/domain/v0/TestResult/dto/response/RatingGetResponse.java index fb25cb2..0d5f4df 100644 --- a/src/main/java/com/moplus/moplus_server/domain/TestResult/dto/response/RatingGetResponse.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/dto/response/RatingGetResponse.java @@ -1,4 +1,4 @@ -package com.moplus.moplus_server.domain.TestResult.dto.response; +package com.moplus.moplus_server.domain.v0.TestResult.dto.response; import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; diff --git a/src/main/java/com/moplus/moplus_server/domain/TestResult/dto/response/RatingTableGetResponse.java b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/dto/response/RatingTableGetResponse.java similarity index 67% rename from src/main/java/com/moplus/moplus_server/domain/TestResult/dto/response/RatingTableGetResponse.java rename to src/main/java/com/moplus/moplus_server/domain/v0/TestResult/dto/response/RatingTableGetResponse.java index d99082e..7952c28 100644 --- a/src/main/java/com/moplus/moplus_server/domain/TestResult/dto/response/RatingTableGetResponse.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/dto/response/RatingTableGetResponse.java @@ -1,7 +1,7 @@ -package com.moplus.moplus_server.domain.TestResult.dto.response; +package com.moplus.moplus_server.domain.v0.TestResult.dto.response; -import com.moplus.moplus_server.domain.practiceTest.domain.RatingRow; -import com.moplus.moplus_server.domain.practiceTest.domain.RatingTable; +import com.moplus.moplus_server.domain.v0.practiceTest.domain.RatingRow; +import com.moplus.moplus_server.domain.v0.practiceTest.domain.RatingTable; import java.util.List; import lombok.Builder; @@ -10,9 +10,9 @@ public record RatingTableGetResponse( Long id, Long practiceId, String ratingProvider, - List<RatingRow>ratingRows + List<RatingRow> ratingRows ) { - public static RatingTableGetResponse from(RatingTable ratingTable){ + public static RatingTableGetResponse from(RatingTable ratingTable) { return RatingTableGetResponse.builder() .id(ratingTable.getId()) .practiceId(ratingTable.getPracticeTestId()) diff --git a/src/main/java/com/moplus/moplus_server/domain/TestResult/dto/response/TestResultGetResponse.java b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/dto/response/TestResultGetResponse.java similarity index 82% rename from src/main/java/com/moplus/moplus_server/domain/TestResult/dto/response/TestResultGetResponse.java rename to src/main/java/com/moplus/moplus_server/domain/v0/TestResult/dto/response/TestResultGetResponse.java index 6b0a41a..564e8c6 100644 --- a/src/main/java/com/moplus/moplus_server/domain/TestResult/dto/response/TestResultGetResponse.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/dto/response/TestResultGetResponse.java @@ -1,8 +1,6 @@ -package com.moplus.moplus_server.domain.TestResult.dto.response; +package com.moplus.moplus_server.domain.v0.TestResult.dto.response; -import com.moplus.moplus_server.domain.TestResult.entity.EstimatedRating; -import com.moplus.moplus_server.domain.TestResult.entity.TestResult; -import io.swagger.v3.oas.annotations.media.Schema; +import com.moplus.moplus_server.domain.v0.TestResult.entity.TestResult; import java.time.Duration; import java.util.List; import lombok.Builder; @@ -24,7 +22,7 @@ public static TestResultGetResponse of( List<EstimatedRatingGetResponse> estimatedRatingGetResponses, List<IncorrectProblemGetResponse> incorrectProblems, List<RatingTableGetResponse> ratingTables -) { + ) { return TestResultGetResponse.builder() .testResultId(testResult.getId()) .score(testResult.getScore()) diff --git a/src/main/java/com/moplus/moplus_server/domain/TestResult/entity/EstimatedRating.java b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/entity/EstimatedRating.java similarity index 86% rename from src/main/java/com/moplus/moplus_server/domain/TestResult/entity/EstimatedRating.java rename to src/main/java/com/moplus/moplus_server/domain/v0/TestResult/entity/EstimatedRating.java index b71238e..c8c9c08 100644 --- a/src/main/java/com/moplus/moplus_server/domain/TestResult/entity/EstimatedRating.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/entity/EstimatedRating.java @@ -1,7 +1,7 @@ -package com.moplus.moplus_server.domain.TestResult.entity; +package com.moplus.moplus_server.domain.v0.TestResult.entity; -import com.moplus.moplus_server.domain.practiceTest.domain.RatingRow; -import com.moplus.moplus_server.domain.practiceTest.domain.RatingTable; +import com.moplus.moplus_server.domain.v0.practiceTest.domain.RatingRow; +import com.moplus.moplus_server.domain.v0.practiceTest.domain.RatingTable; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -27,15 +27,15 @@ public class EstimatedRating { private String ratingProvider; - public EstimatedRating(int estimatedRating, Long testResultId,String ratingProvider) { + public EstimatedRating(int estimatedRating, Long testResultId, String ratingProvider) { this.estimatedRating = estimatedRating; this.testResultId = testResultId; this.ratingProvider = ratingProvider; } - public static EstimatedRating of(int testScore, Long testResultId,RatingTable ratingTables) { + public static EstimatedRating of(int testScore, Long testResultId, RatingTable ratingTables) { int estimatedRating = calculateEstimatedRating(testScore, ratingTables.getRatingRows()); - return new EstimatedRating(estimatedRating, testResultId,ratingTables.getRatingProvider()); + return new EstimatedRating(estimatedRating, testResultId, ratingTables.getRatingProvider()); } private static int calculateEstimatedRating(int testScore, List<RatingRow> ratingRows) { @@ -47,7 +47,7 @@ private static int calculateEstimatedRating(int testScore, List<RatingRow> ratin int[] scoreRange = parseRawScores(rawScores); int min = scoreRange[0]; int max = scoreRange[1]; - if(testScore >= min){ + if (testScore >= min) { return i + 1; } } diff --git a/src/main/java/com/moplus/moplus_server/domain/TestResult/entity/IncorrectProblem.java b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/entity/IncorrectProblem.java similarity index 90% rename from src/main/java/com/moplus/moplus_server/domain/TestResult/entity/IncorrectProblem.java rename to src/main/java/com/moplus/moplus_server/domain/v0/TestResult/entity/IncorrectProblem.java index 2a8de67..08dcfb9 100644 --- a/src/main/java/com/moplus/moplus_server/domain/TestResult/entity/IncorrectProblem.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/entity/IncorrectProblem.java @@ -1,4 +1,4 @@ -package com.moplus.moplus_server.domain.TestResult.entity; +package com.moplus.moplus_server.domain.v0.TestResult.entity; import com.moplus.moplus_server.global.common.BaseEntity; import jakarta.persistence.Column; @@ -36,8 +36,8 @@ public class IncorrectProblem extends BaseEntity { @Builder public IncorrectProblem(Long problemId, Long practiceTestId, String incorrectAnswer, String problemNumber, - int point, - TestResult testResult, double correctRate) { + int point, + TestResult testResult, double correctRate) { this.problemId = problemId; this.practiceTestId = practiceTestId; this.incorrectAnswer = incorrectAnswer; diff --git a/src/main/java/com/moplus/moplus_server/domain/TestResult/entity/TestResult.java b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/entity/TestResult.java similarity index 88% rename from src/main/java/com/moplus/moplus_server/domain/TestResult/entity/TestResult.java rename to src/main/java/com/moplus/moplus_server/domain/v0/TestResult/entity/TestResult.java index 7632937..d0e096b 100644 --- a/src/main/java/com/moplus/moplus_server/domain/TestResult/entity/TestResult.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/entity/TestResult.java @@ -1,4 +1,4 @@ -package com.moplus.moplus_server.domain.TestResult.entity; +package com.moplus.moplus_server.domain.v0.TestResult.entity; import com.moplus.moplus_server.global.common.BaseEntity; import jakarta.persistence.Column; @@ -7,7 +7,6 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import java.time.Duration; -import java.time.LocalTime; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -37,8 +36,8 @@ public TestResult(int score, Duration solvingTime, Long practiceTestId) { public static TestResult fromPracticeTestId(Long practiceTestId) { return TestResult.builder() - .practiceTestId(practiceTestId) - .build(); + .practiceTestId(practiceTestId) + .build(); } public void addScore(int score) { diff --git a/src/main/java/com/moplus/moplus_server/domain/TestResult/entity/TestScoreCalculator.java b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/entity/TestScoreCalculator.java similarity index 72% rename from src/main/java/com/moplus/moplus_server/domain/TestResult/entity/TestScoreCalculator.java rename to src/main/java/com/moplus/moplus_server/domain/v0/TestResult/entity/TestScoreCalculator.java index 6332c98..291806d 100644 --- a/src/main/java/com/moplus/moplus_server/domain/TestResult/entity/TestScoreCalculator.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/entity/TestScoreCalculator.java @@ -1,6 +1,6 @@ -package com.moplus.moplus_server.domain.TestResult.entity; +package com.moplus.moplus_server.domain.v0.TestResult.entity; -import com.moplus.moplus_server.domain.practiceTest.domain.PracticeTest; +import com.moplus.moplus_server.domain.v0.practiceTest.domain.PracticeTest; import com.moplus.moplus_server.global.error.exception.ErrorCode; import com.moplus.moplus_server.global.error.exception.InvalidValueException; import java.util.List; @@ -11,10 +11,10 @@ @RequiredArgsConstructor public class TestScoreCalculator { - public int calculateScore(List<IncorrectProblem> incorrectProblems, PracticeTest practiceTest){ + public int calculateScore(List<IncorrectProblem> incorrectProblems, PracticeTest practiceTest) { int minusPoint = incorrectProblems.stream() - .mapToInt(IncorrectProblem::getPoint) - .sum(); + .mapToInt(IncorrectProblem::getPoint) + .sum(); int score = practiceTest.getSubject().getPerfectScore() - minusPoint; diff --git a/src/main/java/com/moplus/moplus_server/domain/TestResult/repository/EstimatedRatingRepository.java b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/repository/EstimatedRatingRepository.java similarity index 63% rename from src/main/java/com/moplus/moplus_server/domain/TestResult/repository/EstimatedRatingRepository.java rename to src/main/java/com/moplus/moplus_server/domain/v0/TestResult/repository/EstimatedRatingRepository.java index 183cb4d..76e67e8 100644 --- a/src/main/java/com/moplus/moplus_server/domain/TestResult/repository/EstimatedRatingRepository.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/repository/EstimatedRatingRepository.java @@ -1,6 +1,6 @@ -package com.moplus.moplus_server.domain.TestResult.repository; +package com.moplus.moplus_server.domain.v0.TestResult.repository; -import com.moplus.moplus_server.domain.TestResult.entity.EstimatedRating; +import com.moplus.moplus_server.domain.v0.TestResult.entity.EstimatedRating; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/src/main/java/com/moplus/moplus_server/domain/TestResult/repository/IncorrectProblemRepository.java b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/repository/IncorrectProblemRepository.java similarity index 63% rename from src/main/java/com/moplus/moplus_server/domain/TestResult/repository/IncorrectProblemRepository.java rename to src/main/java/com/moplus/moplus_server/domain/v0/TestResult/repository/IncorrectProblemRepository.java index db92950..d8059db 100644 --- a/src/main/java/com/moplus/moplus_server/domain/TestResult/repository/IncorrectProblemRepository.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/repository/IncorrectProblemRepository.java @@ -1,6 +1,6 @@ -package com.moplus.moplus_server.domain.TestResult.repository; +package com.moplus.moplus_server.domain.v0.TestResult.repository; -import com.moplus.moplus_server.domain.TestResult.entity.IncorrectProblem; +import com.moplus.moplus_server.domain.v0.TestResult.entity.IncorrectProblem; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/src/main/java/com/moplus/moplus_server/domain/TestResult/repository/TestResultRepository.java b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/repository/TestResultRepository.java similarity index 69% rename from src/main/java/com/moplus/moplus_server/domain/TestResult/repository/TestResultRepository.java rename to src/main/java/com/moplus/moplus_server/domain/v0/TestResult/repository/TestResultRepository.java index 3eafb2d..2d5a3c1 100644 --- a/src/main/java/com/moplus/moplus_server/domain/TestResult/repository/TestResultRepository.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/repository/TestResultRepository.java @@ -1,6 +1,6 @@ -package com.moplus.moplus_server.domain.TestResult.repository; +package com.moplus.moplus_server.domain.v0.TestResult.repository; -import com.moplus.moplus_server.domain.TestResult.entity.TestResult; +import com.moplus.moplus_server.domain.v0.TestResult.entity.TestResult; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/service/IncorrectProblemService.java b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/service/IncorrectProblemService.java new file mode 100644 index 0000000..7eb3f45 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/service/IncorrectProblemService.java @@ -0,0 +1,50 @@ +package com.moplus.moplus_server.domain.v0.TestResult.service; + +import com.moplus.moplus_server.domain.v0.TestResult.dto.request.IncorrectProblemPostRequest; +import com.moplus.moplus_server.domain.v0.TestResult.dto.response.IncorrectProblemGetResponse; +import com.moplus.moplus_server.domain.v0.TestResult.entity.IncorrectProblem; +import com.moplus.moplus_server.domain.v0.TestResult.entity.TestResult; +import com.moplus.moplus_server.domain.v0.TestResult.repository.IncorrectProblemRepository; +import com.moplus.moplus_server.domain.v0.practiceTest.domain.ProblemForTest; +import com.moplus.moplus_server.domain.v0.practiceTest.service.client.ProblemService; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class IncorrectProblemService { + + private final IncorrectProblemRepository incorrectProblemRepository; + private final ProblemService problemService; + + public List<IncorrectProblem> saveIncorrectProblems( + List<IncorrectProblemPostRequest> requests, + Long practiceTestId, + TestResult testResult) { + List<ProblemForTest> problemForTests = requests.stream() + .map(request -> problemService.getProblemByPracticeTestIdAndNumber(practiceTestId, + request.problemNumber())) + .toList(); + + List<IncorrectProblem> incorrectProblems = new ArrayList<>(); + for (int i = 0; i < requests.size(); i++) { + ProblemForTest matchedProblemForTest = problemForTests.get(i); + IncorrectProblem tempIncorrectProblem = requests.get(i).toEntity(matchedProblemForTest); + + tempIncorrectProblem.setTestResult(testResult); + tempIncorrectProblem.setPracticeTestId(practiceTestId); + tempIncorrectProblem.setCorrectRate(matchedProblemForTest.getCorrectRate()); + IncorrectProblem save = incorrectProblemRepository.save(tempIncorrectProblem); + incorrectProblems.add(save); + } + return incorrectProblems; + } + + public List<IncorrectProblemGetResponse> getResponsesByTestResultId(Long testResultId) { + return incorrectProblemRepository.findAllByTestResultId(testResultId).stream() + .map(IncorrectProblemGetResponse::from) + .toList(); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/TestResult/service/TestResultService.java b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/service/TestResultService.java similarity index 76% rename from src/main/java/com/moplus/moplus_server/domain/TestResult/service/TestResultService.java rename to src/main/java/com/moplus/moplus_server/domain/v0/TestResult/service/TestResultService.java index 6dce0ef..337670f 100644 --- a/src/main/java/com/moplus/moplus_server/domain/TestResult/service/TestResultService.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/TestResult/service/TestResultService.java @@ -1,20 +1,20 @@ -package com.moplus.moplus_server.domain.TestResult.service; - -import com.moplus.moplus_server.domain.TestResult.dto.request.IncorrectProblemPostRequest; -import com.moplus.moplus_server.domain.TestResult.dto.request.SolvingTimePostRequest; -import com.moplus.moplus_server.domain.TestResult.dto.response.EstimatedRatingGetResponse; -import com.moplus.moplus_server.domain.TestResult.dto.response.RatingTableGetResponse; -import com.moplus.moplus_server.domain.TestResult.dto.response.TestResultGetResponse; -import com.moplus.moplus_server.domain.TestResult.entity.EstimatedRating; -import com.moplus.moplus_server.domain.TestResult.entity.IncorrectProblem; -import com.moplus.moplus_server.domain.TestResult.entity.TestResult; -import com.moplus.moplus_server.domain.TestResult.entity.TestScoreCalculator; -import com.moplus.moplus_server.domain.TestResult.repository.EstimatedRatingRepository; -import com.moplus.moplus_server.domain.TestResult.repository.TestResultRepository; -import com.moplus.moplus_server.domain.practiceTest.domain.PracticeTest; -import com.moplus.moplus_server.domain.practiceTest.domain.RatingTable; -import com.moplus.moplus_server.domain.practiceTest.repository.PracticeTestRepository; -import com.moplus.moplus_server.domain.practiceTest.repository.RatingTableRepository; +package com.moplus.moplus_server.domain.v0.TestResult.service; + +import com.moplus.moplus_server.domain.v0.TestResult.dto.request.IncorrectProblemPostRequest; +import com.moplus.moplus_server.domain.v0.TestResult.dto.request.SolvingTimePostRequest; +import com.moplus.moplus_server.domain.v0.TestResult.dto.response.EstimatedRatingGetResponse; +import com.moplus.moplus_server.domain.v0.TestResult.dto.response.RatingTableGetResponse; +import com.moplus.moplus_server.domain.v0.TestResult.dto.response.TestResultGetResponse; +import com.moplus.moplus_server.domain.v0.TestResult.entity.EstimatedRating; +import com.moplus.moplus_server.domain.v0.TestResult.entity.IncorrectProblem; +import com.moplus.moplus_server.domain.v0.TestResult.entity.TestResult; +import com.moplus.moplus_server.domain.v0.TestResult.entity.TestScoreCalculator; +import com.moplus.moplus_server.domain.v0.TestResult.repository.EstimatedRatingRepository; +import com.moplus.moplus_server.domain.v0.TestResult.repository.TestResultRepository; +import com.moplus.moplus_server.domain.v0.practiceTest.domain.PracticeTest; +import com.moplus.moplus_server.domain.v0.practiceTest.domain.RatingTable; +import com.moplus.moplus_server.domain.v0.practiceTest.repository.PracticeTestRepository; +import com.moplus.moplus_server.domain.v0.practiceTest.repository.RatingTableRepository; import com.moplus.moplus_server.global.error.exception.ErrorCode; import com.moplus.moplus_server.global.error.exception.NotFoundException; import java.time.Duration; @@ -34,6 +34,10 @@ public class TestResultService { private final RatingTableRepository ratingTableRepository; private final EstimatedRatingRepository estimatedRatingRepository; + private static boolean hasRawScore(RatingTable ratingTable) { + return !ratingTable.getRatingRows().get(0).getRawScores().isBlank(); + } + @Transactional public Long createTestResult(Long practiceTestId, List<IncorrectProblemPostRequest> requests) { PracticeTest practiceTest = getPracticeTestById(practiceTestId); @@ -64,7 +68,8 @@ public TestResultGetResponse getTestResult(Long testResultId) { List<RatingTableGetResponse> ratingTableGetResponses = getRatingTableGetResponses( practiceTest); - List<EstimatedRatingGetResponse> estimatedRatingGetResponses = estimatedRatingRepository.findAllByTestResultId(testResultId).stream() + List<EstimatedRatingGetResponse> estimatedRatingGetResponses = estimatedRatingRepository.findAllByTestResultId( + testResultId).stream() .map(EstimatedRatingGetResponse::from) .toList(); @@ -115,8 +120,4 @@ private List<RatingTableGetResponse> getRatingTableGetResponses(PracticeTest pra .map(RatingTableGetResponse::from) .toList(); } - - private static boolean hasRawScore(RatingTable ratingTable) { - return !ratingTable.getRatingRows().get(0).getRawScores().isBlank(); - } } diff --git a/src/main/java/com/moplus/moplus_server/domain/practiceTest/api/admin/PracticeTestAdminController.java b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/api/admin/PracticeTestAdminController.java similarity index 56% rename from src/main/java/com/moplus/moplus_server/domain/practiceTest/api/admin/PracticeTestAdminController.java rename to src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/api/admin/PracticeTestAdminController.java index 1288a85..473b320 100644 --- a/src/main/java/com/moplus/moplus_server/domain/practiceTest/api/admin/PracticeTestAdminController.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/api/admin/PracticeTestAdminController.java @@ -1,20 +1,17 @@ -package com.moplus.moplus_server.domain.practiceTest.api.admin; +package com.moplus.moplus_server.domain.v0.practiceTest.api.admin; -import com.moplus.moplus_server.domain.practiceTest.dto.client.response.PracticeTestGetResponse; -import com.moplus.moplus_server.domain.practiceTest.domain.PracticeTest; -import com.moplus.moplus_server.domain.practiceTest.service.client.PracticeTestService; -import com.moplus.moplus_server.domain.practiceTest.service.client.ProblemService; +import com.moplus.moplus_server.domain.v0.practiceTest.dto.client.response.PracticeTestGetResponse; +import com.moplus.moplus_server.domain.v0.practiceTest.service.client.PracticeTestService; +import com.moplus.moplus_server.domain.v0.practiceTest.service.client.ProblemService; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; -import jakarta.servlet.http.HttpServletRequest; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; 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.RequestParam; +@Hidden @Controller @RequiredArgsConstructor public class PracticeTestAdminController { @@ -32,5 +29,4 @@ public String listPracticeTests(Model model) { } - } diff --git a/src/main/java/com/moplus/moplus_server/domain/practiceTest/api/admin/PracticeTestCreateController.java b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/api/admin/PracticeTestCreateController.java similarity index 80% rename from src/main/java/com/moplus/moplus_server/domain/practiceTest/api/admin/PracticeTestCreateController.java rename to src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/api/admin/PracticeTestCreateController.java index 8766445..75b9588 100644 --- a/src/main/java/com/moplus/moplus_server/domain/practiceTest/api/admin/PracticeTestCreateController.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/api/admin/PracticeTestCreateController.java @@ -1,9 +1,10 @@ -package com.moplus.moplus_server.domain.practiceTest.api.admin; +package com.moplus.moplus_server.domain.v0.practiceTest.api.admin; -import com.moplus.moplus_server.domain.practiceTest.dto.admin.request.PracticeTestRequest; -import com.moplus.moplus_server.domain.practiceTest.service.admin.PracticeTestAdminService; -import com.moplus.moplus_server.domain.practiceTest.service.client.PracticeTestService; -import com.moplus.moplus_server.domain.practiceTest.service.client.ProblemService; +import com.moplus.moplus_server.domain.v0.practiceTest.dto.admin.request.PracticeTestRequest; +import com.moplus.moplus_server.domain.v0.practiceTest.service.admin.PracticeTestAdminService; +import com.moplus.moplus_server.domain.v0.practiceTest.service.client.PracticeTestService; +import com.moplus.moplus_server.domain.v0.practiceTest.service.client.ProblemService; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Controller; @@ -14,6 +15,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; +@Hidden @Controller @RequestMapping("/admin/practiceTests") @RequiredArgsConstructor @@ -48,7 +50,8 @@ public String submitCreateTestInfo(@ModelAttribute PracticeTestRequest practiceT @PostMapping("/submit/{id}") @Operation(summary = "모의고사 정보 수정 요청") - public String submitUpdateTestInfo(@PathVariable("id") Long id, @ModelAttribute PracticeTestRequest practiceTestRequest, Model model) { + public String submitUpdateTestInfo(@PathVariable("id") Long id, + @ModelAttribute PracticeTestRequest practiceTestRequest, Model model) { practiceTestAdminService.updatePracticeTest(id, practiceTestRequest); practiceTestAdminService.getProblemUpdateModel(model, id); diff --git a/src/main/java/com/moplus/moplus_server/domain/practiceTest/api/admin/ProblemAdminController.java b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/api/admin/ProblemAdminController.java similarity index 81% rename from src/main/java/com/moplus/moplus_server/domain/practiceTest/api/admin/ProblemAdminController.java rename to src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/api/admin/ProblemAdminController.java index 818337c..43211a5 100644 --- a/src/main/java/com/moplus/moplus_server/domain/practiceTest/api/admin/ProblemAdminController.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/api/admin/ProblemAdminController.java @@ -1,8 +1,9 @@ -package com.moplus.moplus_server.domain.practiceTest.api.admin; +package com.moplus.moplus_server.domain.v0.practiceTest.api.admin; -import com.moplus.moplus_server.domain.practiceTest.domain.PracticeTest; -import com.moplus.moplus_server.domain.practiceTest.service.client.PracticeTestService; -import com.moplus.moplus_server.domain.practiceTest.service.client.ProblemService; +import com.moplus.moplus_server.domain.v0.practiceTest.domain.PracticeTest; +import com.moplus.moplus_server.domain.v0.practiceTest.service.client.PracticeTestService; +import com.moplus.moplus_server.domain.v0.practiceTest.service.client.ProblemService; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; @@ -12,6 +13,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; +@Hidden @Controller @RequestMapping("/admin/practiceTests") @RequiredArgsConstructor diff --git a/src/main/java/com/moplus/moplus_server/domain/practiceTest/api/admin/ProblemImageUploadController.java b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/api/admin/ProblemImageUploadController.java similarity index 77% rename from src/main/java/com/moplus/moplus_server/domain/practiceTest/api/admin/ProblemImageUploadController.java rename to src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/api/admin/ProblemImageUploadController.java index 198e4a2..db75303 100644 --- a/src/main/java/com/moplus/moplus_server/domain/practiceTest/api/admin/ProblemImageUploadController.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/api/admin/ProblemImageUploadController.java @@ -1,6 +1,7 @@ -package com.moplus.moplus_server.domain.practiceTest.api.admin; +package com.moplus.moplus_server.domain.v0.practiceTest.api.admin; -import com.moplus.moplus_server.domain.practiceTest.service.admin.ProblemImageUploadService; +import com.moplus.moplus_server.domain.v0.practiceTest.service.admin.ProblemImageUploadService; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Controller; @@ -10,9 +11,9 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; +@Hidden @Controller @RequestMapping("/admin/practiceTests") @RequiredArgsConstructor @@ -31,9 +32,10 @@ public String showImageUploadPage(@PathVariable("practiceTestId") Long practiceT @PostMapping("/uploadImage/{problemId}") @Operation(summary = "문제 이미지 업로드 요청") - public String uploadImage(@RequestParam("practiceTestId") Long practiceTestId, @PathVariable("problemId") Long problemId, @RequestParam("image") MultipartFile image) { + public String uploadImage(@RequestParam("practiceTestId") Long practiceTestId, + @PathVariable("problemId") Long problemId, @RequestParam("image") MultipartFile image) { // 이미지 업로드 처리 - problemImageUploadService.uploadImage(practiceTestId ,problemId, image); + problemImageUploadService.uploadImage(practiceTestId, problemId, image); return "redirect:/admin/practiceTests/imageUploadPage/" + practiceTestId; } diff --git a/src/main/java/com/moplus/moplus_server/domain/practiceTest/api/client/PracticeTestController.java b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/api/client/PracticeTestController.java similarity index 84% rename from src/main/java/com/moplus/moplus_server/domain/practiceTest/api/client/PracticeTestController.java rename to src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/api/client/PracticeTestController.java index 2db3327..9dab596 100644 --- a/src/main/java/com/moplus/moplus_server/domain/practiceTest/api/client/PracticeTestController.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/api/client/PracticeTestController.java @@ -1,8 +1,8 @@ -package com.moplus.moplus_server.domain.practiceTest.api.client; +package com.moplus.moplus_server.domain.v0.practiceTest.api.client; -import com.moplus.moplus_server.domain.practiceTest.dto.client.response.PracticeTestGetResponse; -import com.moplus.moplus_server.domain.practiceTest.service.client.OptimisticLockPracticeTestFacade; -import com.moplus.moplus_server.domain.practiceTest.service.client.PracticeTestService; +import com.moplus.moplus_server.domain.v0.practiceTest.dto.client.response.PracticeTestGetResponse; +import com.moplus.moplus_server.domain.v0.practiceTest.service.client.PracticeTestService; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import java.util.List; import lombok.RequiredArgsConstructor; @@ -13,6 +13,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Hidden @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/practiceTests") diff --git a/src/main/java/com/moplus/moplus_server/domain/practiceTest/domain/AnswerFormat.java b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/domain/AnswerFormat.java similarity index 53% rename from src/main/java/com/moplus/moplus_server/domain/practiceTest/domain/AnswerFormat.java rename to src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/domain/AnswerFormat.java index c7bcc81..3c71f3b 100644 --- a/src/main/java/com/moplus/moplus_server/domain/practiceTest/domain/AnswerFormat.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/domain/AnswerFormat.java @@ -1,4 +1,4 @@ -package com.moplus.moplus_server.domain.practiceTest.domain; +package com.moplus.moplus_server.domain.v0.practiceTest.domain; import java.util.Arrays; import lombok.AllArgsConstructor; @@ -15,8 +15,8 @@ public enum AnswerFormat { public static AnswerFormat fromValue(String value) { return Arrays.stream(AnswerFormat.values()) - .filter(answerFormat -> answerFormat.value.equals(value)) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("해당 값에 맞는 answerFormat가 없습니다: " + value)); + .filter(answerFormat -> answerFormat.value.equals(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("해당 값에 맞는 answerFormat가 없습니다: " + value)); } } diff --git a/src/main/java/com/moplus/moplus_server/domain/practiceTest/domain/FileExtension.java b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/domain/FileExtension.java similarity index 92% rename from src/main/java/com/moplus/moplus_server/domain/practiceTest/domain/FileExtension.java rename to src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/domain/FileExtension.java index 2111311..256a283 100644 --- a/src/main/java/com/moplus/moplus_server/domain/practiceTest/domain/FileExtension.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/domain/FileExtension.java @@ -1,4 +1,4 @@ -package com.moplus.moplus_server.domain.practiceTest.domain; +package com.moplus.moplus_server.domain.v0.practiceTest.domain; import com.moplus.moplus_server.global.error.exception.ErrorCode; import com.moplus.moplus_server.global.error.exception.NotFoundException; diff --git a/src/main/java/com/moplus/moplus_server/domain/practiceTest/domain/PracticeTest.java b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/domain/PracticeTest.java similarity index 74% rename from src/main/java/com/moplus/moplus_server/domain/practiceTest/domain/PracticeTest.java rename to src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/domain/PracticeTest.java index c54fe51..a206e63 100644 --- a/src/main/java/com/moplus/moplus_server/domain/practiceTest/domain/PracticeTest.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/domain/PracticeTest.java @@ -1,6 +1,7 @@ -package com.moplus.moplus_server.domain.practiceTest.domain; +package com.moplus.moplus_server.domain.v0.practiceTest.domain; -import com.moplus.moplus_server.domain.practiceTest.dto.admin.request.PracticeTestRequest; +import com.moplus.moplus_server.domain.problem.domain.practiceTest.Subject; +import com.moplus.moplus_server.domain.v0.practiceTest.dto.admin.request.PracticeTestRequest; import com.moplus.moplus_server.global.common.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -9,7 +10,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.Version; +import jakarta.persistence.Table; import java.time.Duration; import lombok.Builder; import lombok.Getter; @@ -17,6 +18,7 @@ @Getter @Entity +@Table(name = "practice_test") @NoArgsConstructor public class PracticeTest extends BaseEntity { @@ -31,7 +33,7 @@ public class PracticeTest extends BaseEntity { private long viewCount = 0L; private int solvesCount = 0; - private String publicationYear; + private int publicationYear; @Enumerated(EnumType.STRING) private Subject subject; @@ -39,15 +41,16 @@ public class PracticeTest extends BaseEntity { private Duration averageSolvingTime = Duration.ZERO; @Builder - public PracticeTest(String name, String round, String provider, String publicationYear, Subject subject) { + public PracticeTest(String name, String round, String provider, long viewCount, int solvesCount, + int publicationYear, Subject subject, Duration averageSolvingTime) { this.name = name; this.round = round; this.provider = provider; - this.viewCount = 0; - this.solvesCount = 0; + this.viewCount = viewCount; + this.solvesCount = solvesCount; this.publicationYear = publicationYear; this.subject = subject; - this.averageSolvingTime = Duration.ZERO; + this.averageSolvingTime = averageSolvingTime; } public void updateByPracticeTestRequest(PracticeTestRequest request) { diff --git a/src/main/java/com/moplus/moplus_server/domain/practiceTest/domain/Problem.java b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/domain/ProblemForTest.java similarity index 75% rename from src/main/java/com/moplus/moplus_server/domain/practiceTest/domain/Problem.java rename to src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/domain/ProblemForTest.java index 79dcec7..f72fd36 100644 --- a/src/main/java/com/moplus/moplus_server/domain/practiceTest/domain/Problem.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/domain/ProblemForTest.java @@ -1,4 +1,4 @@ -package com.moplus.moplus_server.domain.practiceTest.domain; +package com.moplus.moplus_server.domain.v0.practiceTest.domain; import com.moplus.moplus_server.global.common.BaseEntity; import jakarta.persistence.CascadeType; @@ -20,12 +20,14 @@ @Getter @Entity @NoArgsConstructor -public class Problem extends BaseEntity { +public class ProblemForTest extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "problem_id") + @Column(name = "problem_for_test_id") Long id; - + double correctRate; + @Enumerated(EnumType.STRING) + ProblemRating problemRating; private String problemNumber; @Enumerated(EnumType.STRING) private AnswerFormat answerFormat; @@ -35,23 +37,19 @@ public class Problem extends BaseEntity { private String conceptType; private String unit; private String subunit; - double correctRate; - - @Enumerated(EnumType.STRING) - ProblemRating problemRating; - @ManyToOne() @JoinColumn(name = "practice_test_id") private PracticeTest practiceTest; @OneToOne(cascade = CascadeType.REMOVE, fetch = FetchType.LAZY, orphanRemoval = true) @JoinColumn(name = "problem_image_id") - private ProblemImage image; + private ProblemImageForTest image; @Builder - public Problem(String problemNumber, AnswerFormat answerFormat, String answer, int point, Long incorrectNum, - String conceptType, String unit, String subunit, double correctRate, ProblemRating problemRating, - PracticeTest practiceTest) { + public ProblemForTest(String problemNumber, AnswerFormat answerFormat, String answer, int point, Long incorrectNum, + String conceptType, String unit, String subunit, double correctRate, + ProblemRating problemRating, + PracticeTest practiceTest) { this.problemNumber = problemNumber; this.answerFormat = answerFormat; this.answer = answer; @@ -66,11 +64,11 @@ public Problem(String problemNumber, AnswerFormat answerFormat, String answer, i } - public void addImage(ProblemImage image) { + public void addImage(ProblemImageForTest image) { this.image = image; } - public void calculateProblemRating(){ + public void calculateProblemRating() { this.problemRating = ProblemRating.findProblemRating(this); } @@ -82,7 +80,7 @@ public void updatePoint(int point) { this.point = point; } - public void updateCorrectRate (double correctRate) { + public void updateCorrectRate(double correctRate) { this.correctRate = correctRate; } } diff --git a/src/main/java/com/moplus/moplus_server/domain/practiceTest/domain/ProblemImage.java b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/domain/ProblemImageForTest.java similarity index 68% rename from src/main/java/com/moplus/moplus_server/domain/practiceTest/domain/ProblemImage.java rename to src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/domain/ProblemImageForTest.java index 63ec063..be116c5 100644 --- a/src/main/java/com/moplus/moplus_server/domain/practiceTest/domain/ProblemImage.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/domain/ProblemImageForTest.java @@ -1,9 +1,7 @@ -package com.moplus.moplus_server.domain.practiceTest.domain; +package com.moplus.moplus_server.domain.v0.practiceTest.domain; import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @@ -14,11 +12,11 @@ @Entity @Getter @NoArgsConstructor -public class ProblemImage { +public class ProblemImageForTest { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "problem_image_id") + @Column(name = "problem_image_for_test_id") private Long id; private String fileName; @@ -28,7 +26,7 @@ public class ProblemImage { private Long problemId; @Builder - public ProblemImage(String fileName, String imageUrl, Long problemId) { + public ProblemImageForTest(String fileName, String imageUrl, Long problemId) { this.fileName = fileName; this.imageUrl = imageUrl; this.problemId = problemId; diff --git a/src/main/java/com/moplus/moplus_server/domain/practiceTest/domain/ProblemRating.java b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/domain/ProblemRating.java similarity index 53% rename from src/main/java/com/moplus/moplus_server/domain/practiceTest/domain/ProblemRating.java rename to src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/domain/ProblemRating.java index 742bc55..eece709 100644 --- a/src/main/java/com/moplus/moplus_server/domain/practiceTest/domain/ProblemRating.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/domain/ProblemRating.java @@ -1,4 +1,4 @@ -package com.moplus.moplus_server.domain.practiceTest.domain; +package com.moplus.moplus_server.domain.v0.practiceTest.domain; import java.util.Arrays; import lombok.AllArgsConstructor; @@ -8,12 +8,12 @@ @AllArgsConstructor public enum ProblemRating { - EXTREME("극상위권",0, 0, 30, "최상"), - TIER_1("1등급",1, 30, 50, "상"), - TIER_2("2등급", 2,50, 60, "중상"), - TIER_3("3등급", 3,60, 80, "중"), - TIER_4("4등급", 4,80, 90, "중하"), - OTHER("5등급 이하", 5,90, 100, "하"), + EXTREME("극상위권", 0, 0, 30, "최상"), + TIER_1("1등급", 1, 30, 50, "상"), + TIER_2("2등급", 2, 50, 60, "중상"), + TIER_3("3등급", 3, 60, 80, "중"), + TIER_4("4등급", 4, 80, 90, "중하"), + OTHER("5등급 이하", 5, 90, 100, "하"), ; private String rating; @@ -22,10 +22,10 @@ public enum ProblemRating { private double endCorrectRateRange; private String difficultyLevel; - public static ProblemRating findProblemRating(Problem problem) { + public static ProblemRating findProblemRating(ProblemForTest problemForTest) { return Arrays.stream(values()) - .filter(problemRating -> problemRating.startCorrectRateRange <= problem.getCorrectRate() - && problemRating.endCorrectRateRange > problem.getCorrectRate()) + .filter(problemRating -> problemRating.startCorrectRateRange <= problemForTest.getCorrectRate() + && problemRating.endCorrectRateRange > problemForTest.getCorrectRate()) .findFirst() .orElse(OTHER); } diff --git a/src/main/java/com/moplus/moplus_server/domain/practiceTest/domain/RatingRow.java b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/domain/RatingRow.java similarity index 93% rename from src/main/java/com/moplus/moplus_server/domain/practiceTest/domain/RatingRow.java rename to src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/domain/RatingRow.java index 67b1550..99f25ec 100644 --- a/src/main/java/com/moplus/moplus_server/domain/practiceTest/domain/RatingRow.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/domain/RatingRow.java @@ -1,4 +1,4 @@ -package com.moplus.moplus_server.domain.practiceTest.domain; +package com.moplus.moplus_server.domain.v0.practiceTest.domain; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; @@ -15,7 +15,7 @@ public class RatingRow { private Integer standardScores; private Integer percentiles; - public RatingRow(int index){ + public RatingRow(int index) { this.rating = index; } diff --git a/src/main/java/com/moplus/moplus_server/domain/practiceTest/domain/RatingTable.java b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/domain/RatingTable.java similarity index 85% rename from src/main/java/com/moplus/moplus_server/domain/practiceTest/domain/RatingTable.java rename to src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/domain/RatingTable.java index 9801095..5ac79c2 100644 --- a/src/main/java/com/moplus/moplus_server/domain/practiceTest/domain/RatingTable.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/domain/RatingTable.java @@ -1,7 +1,8 @@ -package com.moplus.moplus_server.domain.practiceTest.domain; +package com.moplus.moplus_server.domain.v0.practiceTest.domain; -import com.moplus.moplus_server.domain.practiceTest.dto.admin.request.RatingTableRequest; -import com.moplus.moplus_server.domain.practiceTest.repository.converter.RatingRowConverter; +import com.moplus.moplus_server.domain.problem.domain.practiceTest.Subject; +import com.moplus.moplus_server.domain.v0.practiceTest.dto.admin.request.RatingTableRequest; +import com.moplus.moplus_server.domain.v0.practiceTest.repository.converter.RatingRowConverter; import com.moplus.moplus_server.global.common.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Convert; diff --git a/src/main/java/com/moplus/moplus_server/domain/practiceTest/dto/admin/request/PracticeTestRequest.java b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/dto/admin/request/PracticeTestRequest.java similarity index 74% rename from src/main/java/com/moplus/moplus_server/domain/practiceTest/dto/admin/request/PracticeTestRequest.java rename to src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/dto/admin/request/PracticeTestRequest.java index c8a392f..b27fb95 100644 --- a/src/main/java/com/moplus/moplus_server/domain/practiceTest/dto/admin/request/PracticeTestRequest.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/dto/admin/request/PracticeTestRequest.java @@ -1,9 +1,7 @@ -package com.moplus.moplus_server.domain.practiceTest.dto.admin.request; +package com.moplus.moplus_server.domain.v0.practiceTest.dto.admin.request; -import com.moplus.moplus_server.domain.practiceTest.domain.PracticeTest; -import com.moplus.moplus_server.domain.practiceTest.domain.RatingRow; -import com.moplus.moplus_server.domain.practiceTest.domain.RatingTable; -import com.moplus.moplus_server.domain.practiceTest.domain.Subject; +import com.moplus.moplus_server.domain.problem.domain.practiceTest.Subject; +import com.moplus.moplus_server.domain.v0.practiceTest.domain.PracticeTest; import java.util.ArrayList; import java.util.List; import lombok.Getter; @@ -18,11 +16,11 @@ public class PracticeTestRequest { private String name; private String round; private String provider; - private String publicationYear; + private int publicationYear; private String subject; - private List<RatingTableRequest> ratingTables = new ArrayList<>(); + private List<RatingTableRequest> ratingTables = new ArrayList<>(); - public PracticeTestRequest(Long id, String name, String round, String provider, String publicationYear, + public PracticeTestRequest(Long id, String name, String round, String provider, int publicationYear, String subject, List<RatingTableRequest> ratingTables) { this.id = id; this.name = name; @@ -33,7 +31,8 @@ public PracticeTestRequest(Long id, String name, String round, String provider, this.ratingTables = ratingTables; } - public static PracticeTestRequest getUpdateModelObject(PracticeTest practiceTest, List<RatingTableRequest> ratingTables) { + public static PracticeTestRequest getUpdateModelObject(PracticeTest practiceTest, + List<RatingTableRequest> ratingTables) { return new PracticeTestRequest( practiceTest.getId(), practiceTest.getName(), practiceTest.getRound(), practiceTest.getProvider(), practiceTest.getPublicationYear(), @@ -52,7 +51,7 @@ public static PracticeTestRequest getUpdateModelObject(PracticeTest practiceTest } public static PracticeTestRequest getCreateModelObject() { - return new PracticeTestRequest(null, "", "", "", "", null,RatingTableRequest.getDefaultRatingTableRequest()); + return new PracticeTestRequest(null, "", "", "", 0, null, RatingTableRequest.getDefaultRatingTableRequest()); } public PracticeTest toEntity() { diff --git a/src/main/java/com/moplus/moplus_server/domain/practiceTest/dto/admin/request/ProblemCreateRequest.java b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/dto/admin/request/ProblemCreateRequest.java similarity index 54% rename from src/main/java/com/moplus/moplus_server/domain/practiceTest/dto/admin/request/ProblemCreateRequest.java rename to src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/dto/admin/request/ProblemCreateRequest.java index 8cb51a5..7c6ff3e 100644 --- a/src/main/java/com/moplus/moplus_server/domain/practiceTest/dto/admin/request/ProblemCreateRequest.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/dto/admin/request/ProblemCreateRequest.java @@ -1,8 +1,8 @@ -package com.moplus.moplus_server.domain.practiceTest.dto.admin.request; +package com.moplus.moplus_server.domain.v0.practiceTest.dto.admin.request; -import com.moplus.moplus_server.domain.practiceTest.domain.AnswerFormat; -import com.moplus.moplus_server.domain.practiceTest.domain.PracticeTest; -import com.moplus.moplus_server.domain.practiceTest.domain.Problem; +import com.moplus.moplus_server.domain.v0.practiceTest.domain.AnswerFormat; +import com.moplus.moplus_server.domain.v0.practiceTest.domain.PracticeTest; +import com.moplus.moplus_server.domain.v0.practiceTest.domain.ProblemForTest; public record ProblemCreateRequest( String problemNumber, @@ -12,8 +12,8 @@ public record ProblemCreateRequest( double correctRate ) { - public Problem toEntity(PracticeTest practiceTest) { - return Problem.builder() + public ProblemForTest toEntity(PracticeTest practiceTest) { + return ProblemForTest.builder() .problemNumber(this.problemNumber()) .answerFormat(AnswerFormat.fromValue(this.answerFormat)) .answer(this.answer) diff --git a/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/dto/admin/request/ProblemImageRequest.java b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/dto/admin/request/ProblemImageRequest.java new file mode 100644 index 0000000..dba2bd4 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/dto/admin/request/ProblemImageRequest.java @@ -0,0 +1,20 @@ +package com.moplus.moplus_server.domain.v0.practiceTest.dto.admin.request; + +import com.moplus.moplus_server.domain.v0.practiceTest.domain.ProblemForTest; +import lombok.Builder; + +@Builder +public record ProblemImageRequest( + Long problemId, + String problemNumber, + String imageUrl +) { + + public static ProblemImageRequest of(ProblemForTest problemForTest) { + return ProblemImageRequest.builder() + .problemId(problemForTest.getId()) + .problemNumber(problemForTest.getProblemNumber()) + .imageUrl(problemForTest.getImage() != null ? problemForTest.getImage().getImageUrl() : null) + .build(); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/dto/admin/request/ProblemPostRequest.java b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/dto/admin/request/ProblemPostRequest.java new file mode 100644 index 0000000..631b814 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/dto/admin/request/ProblemPostRequest.java @@ -0,0 +1,7 @@ +package com.moplus.moplus_server.domain.v0.practiceTest.dto.admin.request; + +public record ProblemPostRequest( + +) { + +} diff --git a/src/main/java/com/moplus/moplus_server/domain/practiceTest/dto/admin/request/RatingTableRequest.java b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/dto/admin/request/RatingTableRequest.java similarity index 85% rename from src/main/java/com/moplus/moplus_server/domain/practiceTest/dto/admin/request/RatingTableRequest.java rename to src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/dto/admin/request/RatingTableRequest.java index a144f36..69bcaea 100644 --- a/src/main/java/com/moplus/moplus_server/domain/practiceTest/dto/admin/request/RatingTableRequest.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/dto/admin/request/RatingTableRequest.java @@ -1,7 +1,7 @@ -package com.moplus.moplus_server.domain.practiceTest.dto.admin.request; +package com.moplus.moplus_server.domain.v0.practiceTest.dto.admin.request; -import com.moplus.moplus_server.domain.practiceTest.domain.RatingRow; -import com.moplus.moplus_server.domain.practiceTest.domain.RatingTable; +import com.moplus.moplus_server.domain.v0.practiceTest.domain.RatingRow; +import com.moplus.moplus_server.domain.v0.practiceTest.domain.RatingTable; import java.util.List; import lombok.Builder; import lombok.Getter; @@ -27,7 +27,8 @@ public RatingTableRequest(Long id, Long practiceId, String ratingProvider, List< public RatingTableRequest(String ratingProvider) { this.ratingProvider = ratingProvider; - this.ratingRows = List.of(new RatingRow(1), new RatingRow(2), new RatingRow(3), new RatingRow(4), new RatingRow(5), + this.ratingRows = List.of(new RatingRow(1), new RatingRow(2), new RatingRow(3), new RatingRow(4), + new RatingRow(5), new RatingRow(6), new RatingRow(7), new RatingRow(8), new RatingRow(9)); } diff --git a/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/dto/admin/response/PracticeTestAdminResponse.java b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/dto/admin/response/PracticeTestAdminResponse.java new file mode 100644 index 0000000..4cda784 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/dto/admin/response/PracticeTestAdminResponse.java @@ -0,0 +1,23 @@ +package com.moplus.moplus_server.domain.v0.practiceTest.dto.admin.response; + +import com.moplus.moplus_server.domain.v0.practiceTest.domain.PracticeTest; +import lombok.Builder; + +@Builder +public record PracticeTestAdminResponse( + Long id, + String name, + String round, + String provider, + String subject +) { + public static PracticeTestAdminResponse from(PracticeTest practiceTest) { + return PracticeTestAdminResponse.builder() + .id(practiceTest.getId()) + .name(practiceTest.getName()) + .provider(practiceTest.getProvider()) + .round(practiceTest.getRound()) + .subject(practiceTest.getSubject().getValue()) + .build(); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/dto/client/response/PracticeTestGetResponse.java b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/dto/client/response/PracticeTestGetResponse.java new file mode 100644 index 0000000..9a0b53a --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/dto/client/response/PracticeTestGetResponse.java @@ -0,0 +1,27 @@ +package com.moplus.moplus_server.domain.v0.practiceTest.dto.client.response; + +import com.moplus.moplus_server.domain.v0.practiceTest.domain.PracticeTest; +import lombok.Builder; + +@Builder +public record PracticeTestGetResponse( + Long id, + String name, + String round, + String provider, + String subject, + long viewCount, + int totalSolvesCount +) { + public static PracticeTestGetResponse from(PracticeTest practiceTest) { + return PracticeTestGetResponse.builder() + .id(practiceTest.getId()) + .name(practiceTest.getName()) + .provider(practiceTest.getProvider()) + .round(practiceTest.getRound()) + .subject(practiceTest.getSubject().getValue()) + .viewCount(practiceTest.getViewCount()) + .totalSolvesCount(practiceTest.getSolvesCount()) + .build(); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/dto/client/response/ProblemGetResponse.java b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/dto/client/response/ProblemGetResponse.java new file mode 100644 index 0000000..5fbea32 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/dto/client/response/ProblemGetResponse.java @@ -0,0 +1,26 @@ +package com.moplus.moplus_server.domain.v0.practiceTest.dto.client.response; + +import com.moplus.moplus_server.domain.v0.practiceTest.domain.ProblemForTest; +import lombok.Builder; + +@Builder +public record ProblemGetResponse( + Long id, + String problemNumber, + String answerFormat, + String answer, + int point, + double correctRate +) { + + public static ProblemGetResponse from(ProblemForTest problemForTest) { + return ProblemGetResponse.builder() + .id(problemForTest.getId()) + .answer(problemForTest.getAnswer()) + .problemNumber(problemForTest.getProblemNumber()) + .answerFormat(problemForTest.getAnswerFormat().getValue()) + .point(problemForTest.getPoint()) + .correctRate(problemForTest.getCorrectRate()) + .build(); + } +} diff --git a/src/main/java/com/moplus/moplus_server/domain/practiceTest/repository/PracticeTestRepository.java b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/repository/PracticeTestRepository.java similarity index 60% rename from src/main/java/com/moplus/moplus_server/domain/practiceTest/repository/PracticeTestRepository.java rename to src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/repository/PracticeTestRepository.java index c621581..e1b5fb1 100644 --- a/src/main/java/com/moplus/moplus_server/domain/practiceTest/repository/PracticeTestRepository.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/repository/PracticeTestRepository.java @@ -1,6 +1,6 @@ -package com.moplus.moplus_server.domain.practiceTest.repository; +package com.moplus.moplus_server.domain.v0.practiceTest.repository; -import com.moplus.moplus_server.domain.practiceTest.domain.PracticeTest; +import com.moplus.moplus_server.domain.v0.practiceTest.domain.PracticeTest; import jakarta.persistence.LockModeType; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; @@ -12,10 +12,6 @@ public interface PracticeTestRepository extends JpaRepository<PracticeTest, Long List<PracticeTest> findAllByOrderByViewCountDesc(); @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("select s from PracticeTest s where s.id = :id") + @Query("select s from PracticeTestTag s where s.id = :id") PracticeTest findByIdWithPessimisticLock(@Param("id") Long id); - - @Lock(LockModeType.OPTIMISTIC) - @Query("select s from PracticeTest s where s.id = :id") - PracticeTest findByIdWithOptimisticLock(@Param("id") Long id); } diff --git a/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/repository/ProblemForTestRepository.java b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/repository/ProblemForTestRepository.java new file mode 100644 index 0000000..b45d893 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/repository/ProblemForTestRepository.java @@ -0,0 +1,24 @@ +package com.moplus.moplus_server.domain.v0.practiceTest.repository; + +import com.moplus.moplus_server.domain.v0.practiceTest.domain.ProblemForTest; +import jakarta.persistence.LockModeType; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ProblemForTestRepository extends JpaRepository<ProblemForTest, Long> { + + List<ProblemForTest> findAllByPracticeTestId(Long id); + + void deleteAllByPracticeTestId(Long id); + + Optional<ProblemForTest> findByProblemNumberAndPracticeTestId(String problemNumber, Long practiceTest_id); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT p FROM ProblemForTest p WHERE p.problemNumber = :problem_number AND p.practiceTest.id = :practice_test_id") + Optional<ProblemForTest> findByProblemNumberAndPracticeTestIdWithPessimisticLock( + @Param("problem_number") String problemNumber, @Param("practice_test_id") Long practiceTest_id); +} diff --git a/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/repository/ProblemImageRepository.java b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/repository/ProblemImageRepository.java new file mode 100644 index 0000000..2034023 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/repository/ProblemImageRepository.java @@ -0,0 +1,10 @@ +package com.moplus.moplus_server.domain.v0.practiceTest.repository; + +import com.moplus.moplus_server.domain.v0.practiceTest.domain.ProblemImageForTest; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProblemImageRepository extends JpaRepository<ProblemImageForTest, Long> { + + Optional<ProblemImageForTest> findByProblemId(Long problemId); +} diff --git a/src/main/java/com/moplus/moplus_server/domain/practiceTest/repository/RatingTableRepository.java b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/repository/RatingTableRepository.java similarity index 67% rename from src/main/java/com/moplus/moplus_server/domain/practiceTest/repository/RatingTableRepository.java rename to src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/repository/RatingTableRepository.java index 4cc9544..023368b 100644 --- a/src/main/java/com/moplus/moplus_server/domain/practiceTest/repository/RatingTableRepository.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/repository/RatingTableRepository.java @@ -1,10 +1,11 @@ -package com.moplus.moplus_server.domain.practiceTest.repository; +package com.moplus.moplus_server.domain.v0.practiceTest.repository; -import com.moplus.moplus_server.domain.practiceTest.domain.RatingTable; +import com.moplus.moplus_server.domain.v0.practiceTest.domain.RatingTable; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; public interface RatingTableRepository extends JpaRepository<RatingTable, Long> { List<RatingTable> findAllByPracticeTestId(Long practiceTestId); + void deleteAllByPracticeTestId(Long practiceTestId); } diff --git a/src/main/java/com/moplus/moplus_server/domain/practiceTest/repository/converter/RatingRowConverter.java b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/repository/converter/RatingRowConverter.java similarity index 79% rename from src/main/java/com/moplus/moplus_server/domain/practiceTest/repository/converter/RatingRowConverter.java rename to src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/repository/converter/RatingRowConverter.java index 98fdd5d..8967e95 100644 --- a/src/main/java/com/moplus/moplus_server/domain/practiceTest/repository/converter/RatingRowConverter.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/repository/converter/RatingRowConverter.java @@ -1,8 +1,8 @@ -package com.moplus.moplus_server.domain.practiceTest.repository.converter; +package com.moplus.moplus_server.domain.v0.practiceTest.repository.converter; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import com.moplus.moplus_server.domain.practiceTest.domain.RatingRow; +import com.moplus.moplus_server.domain.v0.practiceTest.domain.RatingRow; import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; @@ -34,7 +34,8 @@ public List<RatingRow> convertToEntityAttribute(String dbData) { } try { - return objectMapper.readValue(dbData, objectMapper.getTypeFactory().constructCollectionType(List.class, RatingRow.class)); + return objectMapper.readValue(dbData, + objectMapper.getTypeFactory().constructCollectionType(List.class, RatingRow.class)); } catch (JsonProcessingException e) { throw new RuntimeException("JSON 문자열을 RatingRow 목록으로 변환할 수 없습니다 : ", e); } diff --git a/src/main/java/com/moplus/moplus_server/domain/practiceTest/service/admin/PracticeTestAdminService.java b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/service/admin/PracticeTestAdminService.java similarity index 75% rename from src/main/java/com/moplus/moplus_server/domain/practiceTest/service/admin/PracticeTestAdminService.java rename to src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/service/admin/PracticeTestAdminService.java index e47d1cb..e2d7aab 100644 --- a/src/main/java/com/moplus/moplus_server/domain/practiceTest/service/admin/PracticeTestAdminService.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/service/admin/PracticeTestAdminService.java @@ -1,13 +1,13 @@ -package com.moplus.moplus_server.domain.practiceTest.service.admin; - -import com.moplus.moplus_server.domain.practiceTest.domain.PracticeTest; -import com.moplus.moplus_server.domain.practiceTest.domain.RatingTable; -import com.moplus.moplus_server.domain.practiceTest.domain.Subject; -import com.moplus.moplus_server.domain.practiceTest.dto.admin.request.PracticeTestRequest; -import com.moplus.moplus_server.domain.practiceTest.dto.admin.request.RatingTableRequest; -import com.moplus.moplus_server.domain.practiceTest.repository.PracticeTestRepository; -import com.moplus.moplus_server.domain.practiceTest.repository.ProblemRepository; -import com.moplus.moplus_server.domain.practiceTest.repository.RatingTableRepository; +package com.moplus.moplus_server.domain.v0.practiceTest.service.admin; + +import com.moplus.moplus_server.domain.problem.domain.practiceTest.Subject; +import com.moplus.moplus_server.domain.v0.practiceTest.domain.PracticeTest; +import com.moplus.moplus_server.domain.v0.practiceTest.domain.RatingTable; +import com.moplus.moplus_server.domain.v0.practiceTest.dto.admin.request.PracticeTestRequest; +import com.moplus.moplus_server.domain.v0.practiceTest.dto.admin.request.RatingTableRequest; +import com.moplus.moplus_server.domain.v0.practiceTest.repository.PracticeTestRepository; +import com.moplus.moplus_server.domain.v0.practiceTest.repository.ProblemForTestRepository; +import com.moplus.moplus_server.domain.v0.practiceTest.repository.RatingTableRepository; import com.moplus.moplus_server.global.error.exception.ErrorCode; import com.moplus.moplus_server.global.error.exception.NotFoundException; import jakarta.transaction.Transactional; @@ -22,9 +22,25 @@ public class PracticeTestAdminService { private final PracticeTestRepository practiceTestRepository; private final RatingTableRepository ratingTableRepository; - private final ProblemRepository problemRepository; + private final ProblemForTestRepository problemForTestRepository; private final RatingTableAdminService ratingTableAdminService; + private static void addToPracticeTestUpdateModel(Model model, List<RatingTable> ratingTables, + PracticeTest practiceTest) { + if (!ratingTables.isEmpty()) { + List<RatingTableRequest> ratingTableRequests = ratingTables.stream() + .map(RatingTableRequest::getRatingTableRequest) + .toList(); + model.addAttribute("practiceTestRequest", + PracticeTestRequest.getUpdateModelObject(practiceTest, ratingTableRequests)); + } else { + model.addAttribute("practiceTestRequest", PracticeTestRequest.getUpdateModelObject(practiceTest)); + } + } + + private static boolean isMathPracticeTest(PracticeTest practiceTest) { + return List.of("미적분", "확률과통계", "기하", "고1", "고2").contains(practiceTest.getSubject().getValue()); + } public PracticeTest getPracticeTestById(Long id) { return practiceTestRepository.findById(id) @@ -44,22 +60,10 @@ public void getPracticeTestUpdateModel(Model model, Long id) { model.addAttribute("subjects", Subject.values()); } - private static void addToPracticeTestUpdateModel(Model model, List<RatingTable> ratingTables, PracticeTest practiceTest) { - if (!ratingTables.isEmpty()) { - List<RatingTableRequest> ratingTableRequests = ratingTables.stream() - .map(RatingTableRequest::getRatingTableRequest) - .toList(); - model.addAttribute("practiceTestRequest", - PracticeTestRequest.getUpdateModelObject(practiceTest, ratingTableRequests)); - } else { - model.addAttribute("practiceTestRequest", PracticeTestRequest.getUpdateModelObject(practiceTest)); - } - } - @Transactional public void deletePracticeTest(Long id) { ratingTableRepository.deleteAllByPracticeTestId(id); - problemRepository.deleteAllByPracticeTestId(id); + problemForTestRepository.deleteAllByPracticeTestId(id); practiceTestRepository.deleteById(id); } @@ -79,10 +83,7 @@ public void getProblemCreateModel(Model model, Long practiceTestId) { model.addAttribute("practiceTest", practiceTest); model.addAttribute("totalQuestions", totalQuestions); model.addAttribute("hasShortAnswer", isMathPracticeTest(practiceTest)); - } - - private static boolean isMathPracticeTest(PracticeTest practiceTest) { - return List.of("미적분", "확률과통계", "기하").contains(practiceTest.getSubject().getValue()); + model.addAttribute("subject", practiceTest.getSubject().getValue()); } @Transactional @@ -103,5 +104,6 @@ public void getProblemUpdateModel(Model model, Long practiceTestId) { model.addAttribute("practiceTest", practiceTest); model.addAttribute("totalQuestions", totalQuestions); model.addAttribute("hasShortAnswer", isMathPracticeTest(practiceTest)); + model.addAttribute("subject", practiceTest.getSubject().getValue()); } } diff --git a/src/main/java/com/moplus/moplus_server/domain/practiceTest/service/admin/ProblemImageUploadService.java b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/service/admin/ProblemImageUploadService.java similarity index 73% rename from src/main/java/com/moplus/moplus_server/domain/practiceTest/service/admin/ProblemImageUploadService.java rename to src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/service/admin/ProblemImageUploadService.java index 144a313..411f1e1 100644 --- a/src/main/java/com/moplus/moplus_server/domain/practiceTest/service/admin/ProblemImageUploadService.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/service/admin/ProblemImageUploadService.java @@ -1,17 +1,15 @@ -package com.moplus.moplus_server.domain.practiceTest.service.admin; +package com.moplus.moplus_server.domain.v0.practiceTest.service.admin; -import com.amazonaws.services.s3.model.PutObjectRequest; -import com.moplus.moplus_server.domain.practiceTest.domain.FileExtension; -import com.moplus.moplus_server.domain.practiceTest.domain.PracticeTest; -import com.moplus.moplus_server.domain.practiceTest.domain.Problem; -import com.moplus.moplus_server.domain.practiceTest.domain.ProblemImage; -import com.moplus.moplus_server.domain.practiceTest.dto.admin.request.ProblemImageRequest; -import com.moplus.moplus_server.domain.practiceTest.repository.PracticeTestRepository; -import com.moplus.moplus_server.domain.practiceTest.repository.ProblemImageRepository; -import com.moplus.moplus_server.domain.practiceTest.repository.ProblemRepository; +import com.moplus.moplus_server.domain.v0.practiceTest.domain.FileExtension; +import com.moplus.moplus_server.domain.v0.practiceTest.domain.PracticeTest; +import com.moplus.moplus_server.domain.v0.practiceTest.domain.ProblemForTest; +import com.moplus.moplus_server.domain.v0.practiceTest.domain.ProblemImageForTest; +import com.moplus.moplus_server.domain.v0.practiceTest.dto.admin.request.ProblemImageRequest; +import com.moplus.moplus_server.domain.v0.practiceTest.repository.PracticeTestRepository; +import com.moplus.moplus_server.domain.v0.practiceTest.repository.ProblemForTestRepository; +import com.moplus.moplus_server.domain.v0.practiceTest.repository.ProblemImageRepository; import com.moplus.moplus_server.global.error.exception.ErrorCode; import com.moplus.moplus_server.global.error.exception.NotFoundException; -import com.moplus.moplus_server.global.utils.UUIDGenerator; import com.moplus.moplus_server.global.utils.s3.S3Util; import java.io.File; import java.io.IOException; @@ -28,12 +26,13 @@ public class ProblemImageUploadService { private final S3Util s3Util; private final PracticeTestRepository practiceTestRepository; - private final ProblemRepository problemRepository; + private final ProblemForTestRepository problemForTestRepository; private final ProblemImageRepository problemImageRepository; @Transactional(readOnly = true) public void setProblemImagesByPracticeTestId(Long practiceTestId, Model model) { - List<ProblemImageRequest> imageRequests = problemRepository.findAllByPracticeTestId(practiceTestId).stream() + List<ProblemImageRequest> imageRequests = problemForTestRepository.findAllByPracticeTestId(practiceTestId) + .stream() .map(ProblemImageRequest::of) .toList(); model.addAttribute("problemImageRequests", imageRequests); @@ -43,18 +42,18 @@ public void setProblemImagesByPracticeTestId(Long practiceTestId, Model model) { public void uploadImage(Long practiceId, Long problemId, MultipartFile image) { PracticeTest practiceTest = practiceTestRepository.findById(practiceId) .orElseThrow(() -> new NotFoundException(ErrorCode.PRACTICE_TEST_NOT_FOUND)); - Problem problem = problemRepository.findById(problemId) + ProblemForTest problemForTest = problemForTestRepository.findById(problemId) .orElseThrow(() -> new NotFoundException(ErrorCode.PROBLEM_NOT_FOUND)); String fileName = uploadFile(image, problemId, practiceTest.getName()); String s3ObjectUrl = s3Util.getS3ObjectUrl(fileName); - ProblemImage problemImage = ProblemImage.builder() + ProblemImageForTest problemImageForTest = ProblemImageForTest.builder() .fileName(fileName) .problemId(problemId) .imageUrl(s3ObjectUrl) .build(); - ProblemImage saved = problemImageRepository.save(problemImage); - problem.addImage(saved); - problemRepository.save(problem); + ProblemImageForTest saved = problemImageRepository.save(problemImageForTest); + problemForTest.addImage(saved); + problemForTestRepository.save(problemForTest); } public String uploadFile(MultipartFile file, Long problemId, String practiceTestName) { diff --git a/src/main/java/com/moplus/moplus_server/domain/practiceTest/service/admin/RatingTableAdminService.java b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/service/admin/RatingTableAdminService.java similarity index 75% rename from src/main/java/com/moplus/moplus_server/domain/practiceTest/service/admin/RatingTableAdminService.java rename to src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/service/admin/RatingTableAdminService.java index 7e2ad06..a88259a 100644 --- a/src/main/java/com/moplus/moplus_server/domain/practiceTest/service/admin/RatingTableAdminService.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/service/admin/RatingTableAdminService.java @@ -1,8 +1,8 @@ -package com.moplus.moplus_server.domain.practiceTest.service.admin; +package com.moplus.moplus_server.domain.v0.practiceTest.service.admin; -import com.moplus.moplus_server.domain.practiceTest.domain.RatingTable; -import com.moplus.moplus_server.domain.practiceTest.dto.admin.request.RatingTableRequest; -import com.moplus.moplus_server.domain.practiceTest.repository.RatingTableRepository; +import com.moplus.moplus_server.domain.v0.practiceTest.domain.RatingTable; +import com.moplus.moplus_server.domain.v0.practiceTest.dto.admin.request.RatingTableRequest; +import com.moplus.moplus_server.domain.v0.practiceTest.repository.RatingTableRepository; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -24,9 +24,9 @@ public void createRatingTable(RatingTableRequest request, Long practiceId) { public void updateOrSaveRatingTable(Long practiceTestId, List<RatingTableRequest> request) { List<RatingTable> ratingTables = ratingTableRepository.findAllByPracticeTestId(practiceTestId); - if (ratingTables.isEmpty()) + if (ratingTables.isEmpty()) { request.forEach(ratingTableRequest -> createRatingTable(ratingTableRequest, practiceTestId)); - else{ + } else { for (int i = 0; i < ratingTables.size(); i++) { RatingTable ratingTable = ratingTables.get(i); ratingTable.updateByRatingTableRequest(request.get(i)); diff --git a/src/main/java/com/moplus/moplus_server/domain/practiceTest/service/client/OptimisticLockPracticeTestFacade.java b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/service/client/OptimisticLockPracticeTestFacade.java similarity index 88% rename from src/main/java/com/moplus/moplus_server/domain/practiceTest/service/client/OptimisticLockPracticeTestFacade.java rename to src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/service/client/OptimisticLockPracticeTestFacade.java index afdb499..9627a7a 100644 --- a/src/main/java/com/moplus/moplus_server/domain/practiceTest/service/client/OptimisticLockPracticeTestFacade.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/service/client/OptimisticLockPracticeTestFacade.java @@ -1,4 +1,4 @@ -package com.moplus.moplus_server.domain.practiceTest.service.client; +package com.moplus.moplus_server.domain.v0.practiceTest.service.client; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; diff --git a/src/main/java/com/moplus/moplus_server/domain/practiceTest/service/client/PracticeTestService.java b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/service/client/PracticeTestService.java similarity index 64% rename from src/main/java/com/moplus/moplus_server/domain/practiceTest/service/client/PracticeTestService.java rename to src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/service/client/PracticeTestService.java index 8935277..9f99f8a 100644 --- a/src/main/java/com/moplus/moplus_server/domain/practiceTest/service/client/PracticeTestService.java +++ b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/service/client/PracticeTestService.java @@ -1,11 +1,8 @@ -package com.moplus.moplus_server.domain.practiceTest.service.client; - -import com.moplus.moplus_server.domain.practiceTest.dto.admin.request.PracticeTestRequest; -import com.moplus.moplus_server.domain.practiceTest.dto.admin.response.PracticeTestAdminResponse; -import com.moplus.moplus_server.domain.practiceTest.dto.client.response.PracticeTestGetResponse; -import com.moplus.moplus_server.domain.practiceTest.domain.PracticeTest; -import com.moplus.moplus_server.domain.practiceTest.domain.Subject; -import com.moplus.moplus_server.domain.practiceTest.repository.PracticeTestRepository; +package com.moplus.moplus_server.domain.v0.practiceTest.service.client; + +import com.moplus.moplus_server.domain.v0.practiceTest.domain.PracticeTest; +import com.moplus.moplus_server.domain.v0.practiceTest.dto.client.response.PracticeTestGetResponse; +import com.moplus.moplus_server.domain.v0.practiceTest.repository.PracticeTestRepository; import com.moplus.moplus_server.global.error.exception.ErrorCode; import com.moplus.moplus_server.global.error.exception.NotFoundException; import java.util.List; @@ -22,10 +19,10 @@ public class PracticeTestService { @Transactional(readOnly = true) @Cacheable("allPracticeTests") - public List<PracticeTestGetResponse> getAllPracticeTest(){ + public List<PracticeTestGetResponse> getAllPracticeTest() { return practiceTestRepository.findAllByOrderByViewCountDesc().stream() - .map(PracticeTestGetResponse::from) - .toList(); + .map(PracticeTestGetResponse::from) + .toList(); } @Transactional @@ -44,7 +41,7 @@ public void updateSolveCount(Long id) { @Transactional(readOnly = true) public PracticeTest getPracticeTestById(Long id) { return practiceTestRepository.findById(id) - .orElseThrow(() -> new NotFoundException(ErrorCode.PRACTICE_TEST_NOT_FOUND)); + .orElseThrow(() -> new NotFoundException(ErrorCode.PRACTICE_TEST_NOT_FOUND)); } @Transactional(readOnly = true) diff --git a/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/service/client/ProblemService.java b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/service/client/ProblemService.java new file mode 100644 index 0000000..e5560a6 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/v0/practiceTest/service/client/ProblemService.java @@ -0,0 +1,84 @@ +package com.moplus.moplus_server.domain.v0.practiceTest.service.client; + +import com.moplus.moplus_server.domain.v0.practiceTest.domain.PracticeTest; +import com.moplus.moplus_server.domain.v0.practiceTest.domain.ProblemForTest; +import com.moplus.moplus_server.domain.v0.practiceTest.dto.admin.request.ProblemCreateRequest; +import com.moplus.moplus_server.domain.v0.practiceTest.dto.client.response.ProblemGetResponse; +import com.moplus.moplus_server.domain.v0.practiceTest.repository.ProblemForTestRepository; +import com.moplus.moplus_server.global.error.exception.ErrorCode; +import com.moplus.moplus_server.global.error.exception.NotFoundException; +import jakarta.servlet.http.HttpServletRequest; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ProblemService { + + private final ProblemForTestRepository problemForTestRepository; + + @Transactional + public void saveProblems(PracticeTest practiceTest, HttpServletRequest request) { + + List<ProblemCreateRequest> problems = new ArrayList<>(); + + for (int i = 1; i <= practiceTest.getSubject().getProblemCount(); i++) { + String problemNumber = String.valueOf(i); + String answerFormat = request.getParameter("answerFormat_" + i); + String answer = request.getParameter("answer_" + i); + int point = Integer.parseInt(request.getParameter("point_" + i)); + double correctRate = Double.parseDouble(request.getParameter("correctRate_" + i)); + + ProblemCreateRequest problem = new ProblemCreateRequest(problemNumber, answerFormat, answer, point, + correctRate); + problems.add(problem); + } + List<ProblemForTest> problemsEntities = problems.stream() + .map(problem -> problem.toEntity(practiceTest)) + .toList(); + problemsEntities + .forEach(ProblemForTest::calculateProblemRating); + problemForTestRepository.saveAll(problemsEntities); + } + + @Transactional + public void updateProblems(PracticeTest practiceTest, HttpServletRequest request) { + List<ProblemForTest> problemForTests = problemForTestRepository.findAllByPracticeTestId(practiceTest.getId()); + + for (int i = 1; i <= practiceTest.getSubject().getProblemCount(); i++) { + ProblemForTest problemForTest = problemForTests.get(i - 1); + problemForTest.updateAnswer(request.getParameter("answer_" + i)); + problemForTest.updatePoint(Integer.parseInt(request.getParameter("point_" + i))); + problemForTest.updateCorrectRate(Double.parseDouble(request.getParameter("correctRate_" + i))); + + problemForTest.calculateProblemRating(); + problemForTestRepository.save(problemForTest); + } + } + + + public ProblemForTest getProblemByPracticeTestIdAndNumber(Long practiceId, String problemNumber) { + return problemForTestRepository.findByProblemNumberAndPracticeTestId(problemNumber, practiceId) + .orElseThrow(() -> new NotFoundException(ErrorCode.PROBLEM_NOT_FOUND)); + } + + @Transactional + public ProblemForTest updateCorrectRate(Long practiceTestId, String problemNumber, double correctRate) { + ProblemForTest problemForTest = problemForTestRepository.findByProblemNumberAndPracticeTestIdWithPessimisticLock( + problemNumber, + practiceTestId) + .orElseThrow(() -> new NotFoundException(ErrorCode.PROBLEM_NOT_FOUND)); + problemForTest.getPracticeTest(); + problemForTest.updateCorrectRate(correctRate); + return problemForTestRepository.save(problemForTest); + } + + public List<ProblemGetResponse> getProblemsByTestId(Long testId) { + return problemForTestRepository.findAllByPracticeTestId(testId).stream() + .map(ProblemGetResponse::from) + .toList(); + } +} diff --git a/src/main/java/com/moplus/moplus_server/global/annotation/AuthUser.java b/src/main/java/com/moplus/moplus_server/global/annotation/AuthUser.java new file mode 100644 index 0000000..5dd8a36 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/global/annotation/AuthUser.java @@ -0,0 +1,14 @@ +package com.moplus.moplus_server.global.annotation; + +import io.swagger.v3.oas.annotations.Parameter; +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) +@Parameter(hidden = true) +public @interface AuthUser { + +} diff --git a/src/main/java/com/moplus/moplus_server/global/annotation/AuthenticationArgumentResolver.java b/src/main/java/com/moplus/moplus_server/global/annotation/AuthenticationArgumentResolver.java new file mode 100644 index 0000000..3952759 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/global/annotation/AuthenticationArgumentResolver.java @@ -0,0 +1,49 @@ +package com.moplus.moplus_server.global.annotation; + +import com.moplus.moplus_server.domain.member.domain.Member; +import com.moplus.moplus_server.domain.member.repository.MemberRepository; +import com.moplus.moplus_server.global.error.exception.BusinessException; +import com.moplus.moplus_server.global.error.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +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 +public class AuthenticationArgumentResolver implements HandlerMethodArgumentResolver { + + private final MemberRepository memberRepository; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + final boolean isUserAuthAnnotation = parameter.getParameterAnnotation(AuthUser.class) != null; + final boolean isMemberClass = parameter.getParameterType().equals(Member.class); + return isUserAuthAnnotation && isMemberClass; + } + + @Override + public Member resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { + return getCurrentMember(); + } + + private Member getCurrentMember() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || !authentication.isAuthenticated()) { + throw new BusinessException(ErrorCode.AUTH_NOT_FOUND); + } + Object principal = authentication.getPrincipal(); + + if (!(principal instanceof Member)) { + throw new BusinessException(ErrorCode.INVALID_PRINCIPAL); + } + return (Member) principal; + } +} diff --git a/src/main/java/com/moplus/moplus_server/global/common/BaseEntity.java b/src/main/java/com/moplus/moplus_server/global/common/BaseEntity.java index 82a23e2..2e0b341 100644 --- a/src/main/java/com/moplus/moplus_server/global/common/BaseEntity.java +++ b/src/main/java/com/moplus/moplus_server/global/common/BaseEntity.java @@ -4,7 +4,6 @@ import jakarta.persistence.EntityListeners; import jakarta.persistence.MappedSuperclass; import java.time.LocalDateTime; -import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.experimental.SuperBuilder; @@ -26,9 +25,4 @@ public abstract class BaseEntity { @LastModifiedDate @Column(name = "update_at") private LocalDateTime updatedDate; - - @Column(name = "deleted") - @Builder.Default - private boolean deleted = false; - } diff --git a/src/main/java/com/moplus/moplus_server/global/config/WebConfig.java b/src/main/java/com/moplus/moplus_server/global/config/WebConfig.java index 783451c..784551f 100644 --- a/src/main/java/com/moplus/moplus_server/global/config/WebConfig.java +++ b/src/main/java/com/moplus/moplus_server/global/config/WebConfig.java @@ -1,9 +1,13 @@ package com.moplus.moplus_server.global.config; +import com.moplus.moplus_server.global.annotation.AuthenticationArgumentResolver; +import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -13,15 +17,21 @@ @EnableTransactionManagement public class WebConfig implements WebMvcConfigurer { + private final AuthenticationArgumentResolver authenticationArgumentResolver; + @Value("${cors-allowed-origins}") + private List<String> corsAllowedOrigins; @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") - .allowedOrigins("https://dev.mopl.kr","http://dev.mopl.kr", "http://localhost:8080", "https://www.mopl.kr", "http" - + "://localhost:3000") - .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") - .allowedHeaders("*") - .allowCredentials(true); + .allowedOrigins(corsAllowedOrigins.toArray(new String[0])) + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(true); } + @Override + public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) { + resolvers.add(authenticationArgumentResolver); + } } diff --git a/src/main/java/com/moplus/moplus_server/global/config/properties/PropertiesConfig.java b/src/main/java/com/moplus/moplus_server/global/config/properties/PropertiesConfig.java new file mode 100644 index 0000000..07dde35 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/global/config/properties/PropertiesConfig.java @@ -0,0 +1,15 @@ +package com.moplus.moplus_server.global.config.properties; + +import com.moplus.moplus_server.global.properties.jwt.JwtProperties; +import com.moplus.moplus_server.global.properties.swagger.SwaggerProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@EnableConfigurationProperties({ + JwtProperties.class, + SwaggerProperties.class +}) +@Configuration +public class PropertiesConfig { + +} diff --git a/src/main/java/com/moplus/moplus_server/global/config/querydsl/QuerydslConfig.java b/src/main/java/com/moplus/moplus_server/global/config/querydsl/QuerydslConfig.java new file mode 100644 index 0000000..9f9ab4c --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/global/config/querydsl/QuerydslConfig.java @@ -0,0 +1,20 @@ +package com.moplus.moplus_server.global.config.querydsl; + +import com.querydsl.jpa.JPQLTemplates; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QuerydslConfig { + + @Autowired + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(JPQLTemplates.DEFAULT, entityManager); + } +} diff --git a/src/main/java/com/moplus/moplus_server/global/config/security/SecurityConfig.java b/src/main/java/com/moplus/moplus_server/global/config/security/SecurityConfig.java new file mode 100644 index 0000000..62928cd --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/global/config/security/SecurityConfig.java @@ -0,0 +1,135 @@ +package com.moplus.moplus_server.global.config.security; + +import com.moplus.moplus_server.domain.member.service.MemberService; +import com.moplus.moplus_server.global.security.filter.EmailPasswordAuthenticationFilter; +import com.moplus.moplus_server.global.security.filter.JwtAuthenticationFilter; +import com.moplus.moplus_server.global.security.handler.EmailPasswordSuccessHandler; +import com.moplus.moplus_server.global.security.provider.EmailPasswordAuthenticationProvider; +import com.moplus.moplus_server.global.security.provider.JwtTokenProvider; +import com.moplus.moplus_server.global.security.utils.JwtUtil; +import java.util.List; +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.http.HttpStatus; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final MemberService memberService; + private final EmailPasswordSuccessHandler emailPasswordSuccessHandler; + private final JwtUtil jwtUtil; + + private String[] allowUrls = {"/", "/favicon.ico", "/swagger-ui/**", "/v3/**", "/actuator/**", + "/api/v1/auth/reissue"}; + + @Value("${cors-allowed-origins}") + private List<String> corsAllowedOrigins; + + @Bean + public WebSecurityCustomizer configure() { + // filter 안타게 무시 + return (web) -> web.ignoring().requestMatchers(allowUrls); + } + + @Bean + public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception { + AuthenticationManagerBuilder authenticationManagerBuilder = + http.getSharedObject(AuthenticationManagerBuilder.class); + authenticationManagerBuilder + .authenticationProvider(emailPasswordAuthenticationProvider()) + .authenticationProvider(jwtTokenProvider()); + authenticationManagerBuilder.parentAuthenticationManager(null); + return authenticationManagerBuilder.build(); + } + + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .cors(customizer -> customizer.configurationSource(corsConfigurationSource())) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(request -> request + .requestMatchers(allowUrls).permitAll() + .anyRequest().authenticated()); + + http + .exceptionHandling(exception -> + exception.authenticationEntryPoint((request, response, authException) -> + response.setStatus(HttpStatus.UNAUTHORIZED.value()))); // 인증,인가가 되지 않은 요청 시 발생시 + + http.authenticationManager(authenticationManager(http)); + + http + .addFilterAt(emailPasswordAuthenticationFilter(authenticationManager(http)), + UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(jwtAuthenticationFilter(authenticationManager(http)), + UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public EmailPasswordAuthenticationFilter emailPasswordAuthenticationFilter( + AuthenticationManager authenticationManager) throws Exception { + EmailPasswordAuthenticationFilter filter = new EmailPasswordAuthenticationFilter(authenticationManager); + filter.setFilterProcessesUrl("/api/v1/auth/admin/login"); + filter.setAuthenticationSuccessHandler(emailPasswordSuccessHandler); + filter.afterPropertiesSet(); + return filter; + } + + @Bean + public EmailPasswordAuthenticationProvider emailPasswordAuthenticationProvider() { + return new EmailPasswordAuthenticationProvider(memberService); + } + + @Bean + public JwtAuthenticationFilter jwtAuthenticationFilter(AuthenticationManager authenticationManager) + throws Exception { + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(authenticationManager); + filter.afterPropertiesSet(); + return filter; + } + + @Bean + public JwtTokenProvider jwtTokenProvider() { + return new JwtTokenProvider(jwtUtil, memberService); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(corsAllowedOrigins); + configuration.addAllowedMethod("*"); + configuration.setAllowedHeaders(List.of("*")); // 허용할 헤더 + configuration.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); // 모든 경로에 적용 + return source; + } + + @Bean + public BCryptPasswordEncoder bCryptPasswordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/moplus/moplus_server/global/config/swagger/SwaggerConfig.java b/src/main/java/com/moplus/moplus_server/global/config/swagger/SwaggerConfig.java new file mode 100644 index 0000000..15a9e50 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/global/config/swagger/SwaggerConfig.java @@ -0,0 +1,77 @@ +package com.moplus.moplus_server.global.config.swagger; + +import com.moplus.moplus_server.global.properties.swagger.SwaggerProperties; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import java.util.List; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.customizers.OperationCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatus; + +@Configuration +@RequiredArgsConstructor +public class SwaggerConfig { + + private final SwaggerProperties swaggerProperties; + + private SecurityScheme createAPIKeyScheme() { + return new SecurityScheme().type(SecurityScheme.Type.HTTP) + .bearerFormat("JWT") + .scheme("Bearer"); + } + + private List<Server> addServerUrl() { + return swaggerProperties.servers().stream() + .map(serverProp -> new Server() + .url(serverProp.url()) + .description(serverProp.description())) + .collect(Collectors.toList()); + } + + @Bean + public OpenAPI openAPI() { + + return new OpenAPI().addSecurityItem(new SecurityRequirement().addList("JWT")) + .components(new Components().addSecuritySchemes("JWT", createAPIKeyScheme())) + .info(new Info().title("모플 API 명세서") + .description("모플 API 명세서 입니다") + .version("v0.0.1")) + .servers(addServerUrl()); + } + + @Bean + public OperationCustomizer operationCustomizer() { + return (operation, handlerMethod) -> { + this.addResponseBodyWrapperSchemaExample(operation); + return operation; + }; + } + + private void addResponseBodyWrapperSchemaExample(Operation operation) { + final Content content = operation.getResponses().get("200").getContent(); + if (content != null) { + content.forEach((mediaTypeKey, mediaType) -> { + Schema<?> originalSchema = mediaType.getSchema(); + Schema<?> wrappedSchema = wrapSchema(originalSchema); + mediaType.setSchema(wrappedSchema); + }); + } + } + + private Schema<?> wrapSchema(Schema<?> originalSchema) { + final Schema<?> wrapperSchema = new Schema<>(); + wrapperSchema.addProperty("data", originalSchema); + wrapperSchema.setRequired(List.of("data")); + return wrapperSchema; + } +} diff --git a/src/main/java/com/moplus/moplus_server/global/error/ErrorResponse.java b/src/main/java/com/moplus/moplus_server/global/error/ErrorResponse.java index 367f86f..76bae16 100644 --- a/src/main/java/com/moplus/moplus_server/global/error/ErrorResponse.java +++ b/src/main/java/com/moplus/moplus_server/global/error/ErrorResponse.java @@ -1,6 +1,7 @@ package com.moplus.moplus_server.global.error; import com.moplus.moplus_server.global.error.exception.ErrorCode; +import jakarta.validation.constraints.NotNull; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -9,8 +10,9 @@ @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class ErrorResponse { - + @NotNull private String message; + @NotNull private HttpStatus status; private ErrorResponse(final ErrorCode code) { diff --git a/src/main/java/com/moplus/moplus_server/global/error/GlobalExceptionHandler.java b/src/main/java/com/moplus/moplus_server/global/error/GlobalExceptionHandler.java index de5fd60..2e97cd6 100644 --- a/src/main/java/com/moplus/moplus_server/global/error/GlobalExceptionHandler.java +++ b/src/main/java/com/moplus/moplus_server/global/error/GlobalExceptionHandler.java @@ -3,10 +3,14 @@ import com.moplus.moplus_server.global.error.exception.BusinessException; import com.moplus.moplus_server.global.error.exception.ErrorCode; import com.moplus.moplus_server.global.error.exception.NotFoundException; +import com.moplus.moplus_server.global.security.exception.JwtInvalidException; +import java.util.List; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.support.DefaultMessageSourceResolvable; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.transaction.CannotCreateTransactionException; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -25,6 +29,18 @@ protected ResponseEntity<ErrorResponse> handleBusinessException(final BusinessEx return new ResponseEntity<>(response, errorCode.getStatus()); } + @ExceptionHandler(MethodArgumentNotValidException.class) + protected ResponseEntity<ErrorResponse> handleMethodArgumentNotValid(MethodArgumentNotValidException ex) { + final List<String> errors = ex.getBindingResult() + .getAllErrors() + .stream() + .map(DefaultMessageSourceResolvable::getDefaultMessage) + .toList(); + + ErrorResponse response = ErrorResponse.from(ErrorCode.INVALID_INPUT_VALUE); + return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); + } + @ExceptionHandler(NoHandlerFoundException.class) public ResponseEntity<String> handleNoHandlerFoundException(NoHandlerFoundException ex) { return new ResponseEntity<>("존재하지 않은 요청 엔드포인트입니다", HttpStatus.BAD_REQUEST); @@ -54,4 +70,12 @@ protected ResponseEntity<ErrorResponse> handleException(final Exception exceptio final ErrorResponse response = ErrorResponse.from(ErrorCode.INTERNAL_SERVER_ERROR); return new ResponseEntity<>(response, response.getStatus()); } + + @ExceptionHandler(JwtInvalidException.class) + protected ResponseEntity<ErrorResponse> handleJwtInvalidException(final JwtInvalidException exception) { + log.error("handleJwtInvalidException", exception); + final ErrorResponse response = ErrorResponse.from(ErrorCode.BAD_CREDENTIALS); + + return new ResponseEntity<>(response, HttpStatus.UNAUTHORIZED); + } } diff --git a/src/main/java/com/moplus/moplus_server/global/error/exception/AlreadyExistException.java b/src/main/java/com/moplus/moplus_server/global/error/exception/AlreadyExistException.java new file mode 100644 index 0000000..31f1405 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/global/error/exception/AlreadyExistException.java @@ -0,0 +1,7 @@ +package com.moplus.moplus_server.global.error.exception; + +public class AlreadyExistException extends BusinessException { + public AlreadyExistException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/moplus/moplus_server/global/error/exception/ErrorCode.java b/src/main/java/com/moplus/moplus_server/global/error/exception/ErrorCode.java index fb9d7e3..91e42ac 100644 --- a/src/main/java/com/moplus/moplus_server/global/error/exception/ErrorCode.java +++ b/src/main/java/com/moplus/moplus_server/global/error/exception/ErrorCode.java @@ -5,13 +5,45 @@ @Getter public enum ErrorCode { + + //공통 INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 오류, 관리자에게 문의하세요"), + INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "잘못된 입력 값입니다"), + BAD_CREDENTIALS(HttpStatus.UNAUTHORIZED, "잘못된 인증 정보입니다"), + BLANK_INPUT_VALUE(HttpStatus.BAD_REQUEST, "빈 값이 입력되었습니다"), + + //Auth + AUTH_NOT_FOUND(HttpStatus.UNAUTHORIZED, "시큐리티 인증 정보를 찾을 수 없습니다."), + UNKNOWN_ERROR(HttpStatus.UNAUTHORIZED, "알 수 없는 에러"), + EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 Token입니다"), + UNSUPPORTED_TOKEN(HttpStatus.UNAUTHORIZED, "토큰 길이 및 형식이 다른 Token입니다"), + WRONG_TYPE_TOKEN(HttpStatus.UNAUTHORIZED, "서명이 잘못된 토큰입니다."), + ACCESS_DENIED(HttpStatus.UNAUTHORIZED, "토큰이 없습니다"), + TOKEN_SUBJECT_FORMAT_ERROR(HttpStatus.UNAUTHORIZED, "Subject 값에 Long 타입이 아닌 다른 타입이 들어있습니다."), + AT_EXPIRED_AND_RT_NOT_FOUND(HttpStatus.UNAUTHORIZED, "AT는 만료되었고 RT는 비어있습니다."), + RT_NOT_FOUND(HttpStatus.UNAUTHORIZED, "RT가 비어있습니다"), + INVALID_PRINCIPAL(HttpStatus.UNAUTHORIZED, "잘못된 Principal입니다"), //모의고사 PRACTICE_TEST_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 모의고사를 찾을 수 없습니다"), - //문제 - PROBLEM_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 문제를 찾을 수 없습니다"), + //문항 + PROBLEM_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 문항을 찾을 수 없습니다"), + PROBLEM_ALREADY_EXIST(HttpStatus.CONFLICT, "해당 문항는 이미 존재합니다"), + INVALID_MULTIPLE_CHOICE_ANSWER(HttpStatus.BAD_REQUEST, "객관식 문항의 정답은 1~5 사이의 숫자여야 합니다"), + INVALID_SHORT_NUMBER_ANSWER(HttpStatus.BAD_REQUEST, "주관식 문항의 정답은 0~999 사이의 숫자여야 합니다"), + INVALID_CONFIRM_PROBLEM(HttpStatus.BAD_REQUEST, "유효하지 않은 문항들 : "), + INVALID_DIFFICULTY(HttpStatus.BAD_REQUEST, "난이도는 1~10 사이의 숫자여야 합니다"), + + //새끼 문항 + CHILD_PROBLEM_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 새끼 문제를 찾을 수 없습니다"), + CHILD_PROBLEM_UPDATE_AFTER_CONFIRMED(HttpStatus.BAD_REQUEST, "컨펌 후 문제는 수정할 수 없습니다"), + INVALID_CHILD_PROBLEM_SEQUENCE(HttpStatus.BAD_REQUEST, "새끼 문제의 업데이트 순서가 일치하지 않습니다."), + INVALID_CHILD_PROBLEM_SIZE(HttpStatus.BAD_REQUEST, "새끼 문제의 업데이트 개수가 일치하지 않습니다."), + + + //개념태그 + CONCEPT_TAG_NOT_FOUND_IN_LIST(HttpStatus.NOT_FOUND, "해당 리스트 중 존재하지 않는 개념 태그가 있습니다."), //시험결과 TEST_RESULT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 시험 결과지를 찾을 수 없습니다"), @@ -23,6 +55,20 @@ public enum ErrorCode { //이미지 IMAGE_FILE_EXTENSION_NOT_FOUND(HttpStatus.NOT_FOUND, "지원하지 않는 이미지 확장자입니다"), IMAGE_FILE_NOT_FOUND_IN_S3(HttpStatus.NOT_FOUND, "S3에 해당 이미지 파일을 찾을 수 없습니다"), + + //회원 + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 회원을 찾을 수 없습니다"), + + //문항세트 + PROBLEM_SET_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 문항세트를 찾을 수 없습니다"), + EMPTY_PROBLEMS_ERROR(HttpStatus.BAD_REQUEST, "적어도 1개의 문항을 등록해주세요"), + + // 발행 + INVALID_MONTH_ERROR(HttpStatus.BAD_REQUEST, "유효하지 않은 월입니다."), + INVALID_DATE_ERROR(HttpStatus.BAD_REQUEST, "오늘 이후 날짜에만 발행이 가능합니다."), + PUBLISH_NOT_FOUND(HttpStatus.NOT_FOUND, "발행 정보를 찾을 수 없습니다"), + CANNOT_DELETE_PAST_PUBLISH(HttpStatus.BAD_REQUEST, "이미 지난 발행건은 삭제할 수 없습니다."), + ALREADY_PUBLISHED_ERROR(HttpStatus.BAD_REQUEST, "이미 발행된 문항세트는 컨펌해제할 수 없습니다."), ; diff --git a/src/main/java/com/moplus/moplus_server/global/properties/jwt/JwtProperties.java b/src/main/java/com/moplus/moplus_server/global/properties/jwt/JwtProperties.java new file mode 100644 index 0000000..bd71a2e --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/global/properties/jwt/JwtProperties.java @@ -0,0 +1,21 @@ +package com.moplus.moplus_server.global.properties.jwt; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "jwt") +public record JwtProperties( + String accessTokenSecret, + String refreshTokenSecret, + Long accessTokenExpirationTime, + Long refreshTokenExpirationTime, + String issuer +) { + + public Long accessTokenExpirationMilliTime() { + return accessTokenExpirationTime * 1000; + } + + public Long refreshTokenExpirationMilliTime() { + return refreshTokenExpirationTime * 1000; + } +} diff --git a/src/main/java/com/moplus/moplus_server/global/properties/swagger/SwaggerProperties.java b/src/main/java/com/moplus/moplus_server/global/properties/swagger/SwaggerProperties.java new file mode 100644 index 0000000..d6a3a96 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/global/properties/swagger/SwaggerProperties.java @@ -0,0 +1,15 @@ +package com.moplus.moplus_server.global.properties.swagger; + +import java.util.List; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "swagger") +public record SwaggerProperties( + List<ServerProperties> servers +) { + public static record ServerProperties( + String url, + String description + ) { + } +} diff --git a/src/main/java/com/moplus/moplus_server/global/response/FailResponseDto.java b/src/main/java/com/moplus/moplus_server/global/response/FailResponseDto.java new file mode 100644 index 0000000..438897f --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/global/response/FailResponseDto.java @@ -0,0 +1,16 @@ +package com.moplus.moplus_server.global.response; + +import com.moplus.moplus_server.global.error.ErrorResponse; +import jakarta.validation.constraints.NotNull; +import org.springframework.http.HttpStatus; + +public record FailResponseDto( + @NotNull + String message, + @NotNull + HttpStatus status +) { + public static FailResponseDto fail(ErrorResponse errorResponse) { + return new FailResponseDto(errorResponse.getMessage(), errorResponse.getStatus()); + } +} diff --git a/src/main/java/com/moplus/moplus_server/global/response/IdResponse.java b/src/main/java/com/moplus/moplus_server/global/response/IdResponse.java new file mode 100644 index 0000000..8aab8b7 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/global/response/IdResponse.java @@ -0,0 +1,9 @@ +package com.moplus.moplus_server.global.response; + +import jakarta.validation.constraints.NotNull; + +public record IdResponse( + @NotNull(message = "ID는 필수입니다") + Long id +) { +} diff --git a/src/main/java/com/moplus/moplus_server/global/response/ResponseDtoAdvice.java b/src/main/java/com/moplus/moplus_server/global/response/ResponseDtoAdvice.java new file mode 100644 index 0000000..3e9dcfb --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/global/response/ResponseDtoAdvice.java @@ -0,0 +1,38 @@ +package com.moplus.moplus_server.global.response; + +import com.moplus.moplus_server.global.error.ErrorResponse; +import org.springframework.core.MethodParameter; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; + +@RestControllerAdvice( + basePackages = "com.moplus.moplus_server" +) + +public class ResponseDtoAdvice implements ResponseBodyAdvice<Object> { + + @Override + public boolean supports(MethodParameter returnType, Class converterType) { + return !(returnType.getParameterType() == SuccessResponseDto.class) + && MappingJackson2HttpMessageConverter.class.isAssignableFrom(converterType); + } + + @Override + public Object beforeBodyWrite( + Object body, + MethodParameter returnType, + MediaType selectedContentType, + Class selectedConverterType, + ServerHttpRequest request, + ServerHttpResponse response + ) { + if (body instanceof ErrorResponse) { + return FailResponseDto.fail((ErrorResponse) body); + } + return SuccessResponseDto.success(body); + } +} \ No newline at end of file diff --git a/src/main/java/com/moplus/moplus_server/global/response/SuccessResponseDto.java b/src/main/java/com/moplus/moplus_server/global/response/SuccessResponseDto.java new file mode 100644 index 0000000..4b21e03 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/global/response/SuccessResponseDto.java @@ -0,0 +1,12 @@ +package com.moplus.moplus_server.global.response; + +import jakarta.validation.constraints.NotNull; + +public record SuccessResponseDto<T>( + @NotNull + T data +) { + public static <T> SuccessResponseDto<T> success(final T data) { + return new SuccessResponseDto<>(data); + } +} \ No newline at end of file diff --git a/src/main/java/com/moplus/moplus_server/global/scheduler/TestResultScheduler.java b/src/main/java/com/moplus/moplus_server/global/scheduler/TestResultScheduler.java deleted file mode 100644 index 3168c59..0000000 --- a/src/main/java/com/moplus/moplus_server/global/scheduler/TestResultScheduler.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.moplus.moplus_server.global.scheduler; - -import com.moplus.moplus_server.domain.TestResult.entity.TestResult; -import com.moplus.moplus_server.domain.TestResult.repository.TestResultRepository; -import com.moplus.moplus_server.domain.practiceTest.domain.PracticeTest; -import com.moplus.moplus_server.domain.practiceTest.repository.PracticeTestRepository; -import java.time.Duration; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class TestResultScheduler { - - private final PracticeTestRepository practiceTestRepository; - private final TestResultRepository testResultRepository; - - - // 5분마다 실행 (cron 표현식을 사용해 5분마다 스케줄링) - @Scheduled(cron = "0 */5 * * * *") - public void calculateAverageSolvingTime() { - List<PracticeTest> practiceTests = practiceTestRepository.findAll(); - - for (PracticeTest practiceTest : practiceTests) { - if (practiceTest.getSolvesCount() == 0) { - continue; - } - - Duration sum = Duration.ZERO; - List<TestResult> allByPracticeTestId = - testResultRepository.findAllByPracticeTestId(practiceTest.getId()); - - long validCount = 0; - - for (TestResult testResult : allByPracticeTestId) { - Duration solvingTime = testResult.getSolvingTime(); - - // solvingTime이 null이거나 0초일 경우는 제외 - if (solvingTime != null && !solvingTime.isZero()) { - sum = sum.plus(solvingTime); // Duration 객체는 불변이므로 새로운 객체로 할당 - validCount++; // 유효한 solvingTime이 있을 때만 카운트 증가 - } - } - - if (validCount > 0) { - // 유효한 solvingTime이 있는 경우만 평균 계산 - Duration average = sum.dividedBy(validCount); - - // 초 단위까지 포함한 average를 저장 - practiceTest.updateAverageSolvingTime(average); - practiceTestRepository.save(practiceTest); - } - System.out.println("평균 풀이 시간 계산 완료 : " + practiceTest.getId() + "L, 평균 시간 " + practiceTest.getAverageSolvingTime()); - } - } -} diff --git a/src/main/java/com/moplus/moplus_server/global/security/AuthConstants.java b/src/main/java/com/moplus/moplus_server/global/security/AuthConstants.java new file mode 100644 index 0000000..312195b --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/global/security/AuthConstants.java @@ -0,0 +1,13 @@ +package com.moplus.moplus_server.global.security; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class AuthConstants { + + public static final String AUTH_HEADER = "Authorization"; + public static final String TOKEN_TYPE = "BEARER"; + public static final String REFRESH_TOKEN_HEADER = "RefreshToken"; + +} diff --git a/src/main/java/com/moplus/moplus_server/global/security/exception/JwtInvalidException.java b/src/main/java/com/moplus/moplus_server/global/security/exception/JwtInvalidException.java new file mode 100644 index 0000000..e95b846 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/global/security/exception/JwtInvalidException.java @@ -0,0 +1,14 @@ +package com.moplus.moplus_server.global.security.exception; + +import org.springframework.security.core.AuthenticationException; + +public class JwtInvalidException extends AuthenticationException { + + public JwtInvalidException(String msg) { + super(msg); + } + + public JwtInvalidException(String msg, Throwable cause) { + super(msg, cause); + } +} diff --git a/src/main/java/com/moplus/moplus_server/global/security/filter/EmailPasswordAuthenticationFilter.java b/src/main/java/com/moplus/moplus_server/global/security/filter/EmailPasswordAuthenticationFilter.java new file mode 100644 index 0000000..310ad74 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/global/security/filter/EmailPasswordAuthenticationFilter.java @@ -0,0 +1,37 @@ +package com.moplus.moplus_server.global.security.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.moplus.moplus_server.domain.auth.dto.request.AdminLoginRequest; +import com.moplus.moplus_server.global.error.exception.ErrorCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +public class EmailPasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter { + + public EmailPasswordAuthenticationFilter(final AuthenticationManager authenticationManager) { + super.setAuthenticationManager(authenticationManager); + } + + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) + throws AuthenticationException { + final UsernamePasswordAuthenticationToken authRequest; + try { + final AdminLoginRequest loginRequest = new ObjectMapper().readValue(request.getInputStream(), + AdminLoginRequest.class); + authRequest = UsernamePasswordAuthenticationToken.unauthenticated(loginRequest.email(), + loginRequest.password()); + } catch (IOException exception) { + throw new BadCredentialsException(ErrorCode.INVALID_INPUT_VALUE.getMessage()); + } + setDetails(request, authRequest); + return this.getAuthenticationManager().authenticate(authRequest); + } +} diff --git a/src/main/java/com/moplus/moplus_server/global/security/filter/JwtAuthenticationFilter.java b/src/main/java/com/moplus/moplus_server/global/security/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..1f37c3a --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/global/security/filter/JwtAuthenticationFilter.java @@ -0,0 +1,45 @@ +package com.moplus.moplus_server.global.security.filter; + +import com.moplus.moplus_server.global.security.token.JwtAuthenticationToken; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + public static final String TOKEN_PREFIX = "Bearer "; + + private final AuthenticationManager authenticationManager; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String accessToken = extractAccessTokenFromHeader(request); + + if (StringUtils.hasText(accessToken)) { + Authentication jwtAuthenticationToken = new JwtAuthenticationToken(accessToken); + Authentication authentication = authenticationManager.authenticate(jwtAuthenticationToken); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } + + private String extractAccessTokenFromHeader(HttpServletRequest request) { + return Optional.ofNullable(request.getHeader("Authorization")) + .filter(header -> header.startsWith(TOKEN_PREFIX)) + .map(header -> header.replace(TOKEN_PREFIX, "")) + .orElse(null); + } +} \ No newline at end of file diff --git a/src/main/java/com/moplus/moplus_server/global/security/handler/EmailPasswordSuccessHandler.java b/src/main/java/com/moplus/moplus_server/global/security/handler/EmailPasswordSuccessHandler.java new file mode 100644 index 0000000..403e8ea --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/global/security/handler/EmailPasswordSuccessHandler.java @@ -0,0 +1,45 @@ +package com.moplus.moplus_server.global.security.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.moplus.moplus_server.domain.member.domain.Member; +import com.moplus.moplus_server.global.security.utils.CookieUtil; +import com.moplus.moplus_server.global.security.utils.JwtUtil; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class EmailPasswordSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { + + private final JwtUtil jwtUtil; + private final CookieUtil cookieUtil; + private final ObjectMapper objectMapper = new ObjectMapper(); // JSON 변환을 위한 ObjectMapper + + @Override + public void onAuthenticationSuccess(final HttpServletRequest request, final HttpServletResponse response, + final Authentication authentication) throws IOException { + Member member = (Member) authentication.getPrincipal(); + String accessToken = jwtUtil.generateAccessToken(member); + String refreshToken = jwtUtil.generateRefreshToken(member); + + response.addCookie(cookieUtil.createCookie(refreshToken)); + + Map<String, Map<String, String>> commonResponse = new HashMap<>(); + Map<String, String> tokenResponse = new HashMap<>(); + tokenResponse.put("accessToken", accessToken); + + commonResponse.put("data", tokenResponse); + + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(commonResponse)); + } + +} diff --git a/src/main/java/com/moplus/moplus_server/global/security/provider/EmailPasswordAuthenticationProvider.java b/src/main/java/com/moplus/moplus_server/global/security/provider/EmailPasswordAuthenticationProvider.java new file mode 100644 index 0000000..a284656 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/global/security/provider/EmailPasswordAuthenticationProvider.java @@ -0,0 +1,58 @@ +package com.moplus.moplus_server.global.security.provider; + +import com.moplus.moplus_server.domain.member.domain.Member; +import com.moplus.moplus_server.domain.member.service.MemberService; +import com.moplus.moplus_server.global.error.exception.ErrorCode; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.commons.validator.routines.EmailValidator; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +@RequiredArgsConstructor +public class EmailPasswordAuthenticationProvider implements AuthenticationProvider { + + private final MemberService memberService; + + private static void validateEmail(String memberEmail) { + if (!EmailValidator.getInstance().isValid(memberEmail)) { + throw new BadCredentialsException(ErrorCode.BAD_CREDENTIALS.getMessage()); + } + } + + @Override + public Authentication authenticate(final Authentication authentication) throws AuthenticationException { + final UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication; + final String memberEmail = token.getName(); + final String memberPassword = (String) token.getCredentials(); + + validateEmail(memberEmail); + final Member member = getMemberByEmail(memberEmail); + if (!member.isMatchingPassword(memberPassword)) { + throw new BadCredentialsException(ErrorCode.BAD_CREDENTIALS.getMessage()); + } + + return UsernamePasswordAuthenticationToken.authenticated( + member, + memberPassword, + List.of(new SimpleGrantedAuthority(member.getRole().getValue()) + )); + } + + private Member getMemberByEmail(String memberEmail) { + try { + return memberService.getMemberByEmail(memberEmail); + } catch (Exception e) { + throw new BadCredentialsException(ErrorCode.BAD_CREDENTIALS.getMessage()); + } + } + + @Override + public boolean supports(Class<?> authentication) { + return authentication.equals(UsernamePasswordAuthenticationToken.class); + } +} diff --git a/src/main/java/com/moplus/moplus_server/global/security/provider/JwtTokenProvider.java b/src/main/java/com/moplus/moplus_server/global/security/provider/JwtTokenProvider.java new file mode 100644 index 0000000..7d8a608 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/global/security/provider/JwtTokenProvider.java @@ -0,0 +1,69 @@ +package com.moplus.moplus_server.global.security.provider; + +import com.moplus.moplus_server.domain.member.domain.Member; +import com.moplus.moplus_server.domain.member.service.MemberService; +import com.moplus.moplus_server.global.error.exception.ErrorCode; +import com.moplus.moplus_server.global.security.exception.JwtInvalidException; +import com.moplus.moplus_server.global.security.token.JwtAuthenticationToken; +import com.moplus.moplus_server.global.security.utils.JwtUtil; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.security.SignatureException; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class JwtTokenProvider implements AuthenticationProvider { + + private final JwtUtil jwtUtil; + private final MemberService memberService; + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + Claims claims = getClaims(authentication); + final Member member = getMemberById(claims.getSubject()); + + return new JwtAuthenticationToken( + member, + "", + List.of(new SimpleGrantedAuthority(member.getRole().getValue()) + )); + } + + private Claims getClaims(Authentication authentication) { + Claims claims; + try { + claims = jwtUtil.getAccessTokenClaims(authentication); + } catch (ExpiredJwtException expiredJwtException) { + throw new JwtInvalidException(ErrorCode.EXPIRED_TOKEN.getMessage()); + } catch (SignatureException signatureException) { + throw new JwtInvalidException(ErrorCode.WRONG_TYPE_TOKEN.getMessage()); + } catch (MalformedJwtException malformedJwtException) { + throw new JwtInvalidException(ErrorCode.UNSUPPORTED_TOKEN.getMessage()); + } catch (IllegalArgumentException illegalArgumentException) { + throw new JwtInvalidException(ErrorCode.UNKNOWN_ERROR.getMessage()); + } + return claims; + } + + private Member getMemberById(String id) { + try { + return memberService.getMemberById(Long.parseLong(id)); + } catch (Exception e) { + throw new BadCredentialsException(ErrorCode.BAD_CREDENTIALS.getMessage()); + } + } + + @Override + public boolean supports(Class<?> authentication) { + return JwtAuthenticationToken.class.isAssignableFrom(authentication); + } +} diff --git a/src/main/java/com/moplus/moplus_server/global/security/token/JwtAuthenticationToken.java b/src/main/java/com/moplus/moplus_server/global/security/token/JwtAuthenticationToken.java new file mode 100644 index 0000000..c9f6d5f --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/global/security/token/JwtAuthenticationToken.java @@ -0,0 +1,38 @@ +package com.moplus.moplus_server.global.security.token; + +import java.util.Collection; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +public class JwtAuthenticationToken extends AbstractAuthenticationToken { + + private String jsonWebToken; + private Object principal; + private Object credentials; + + public JwtAuthenticationToken(String jsonWebToken) { + super(null); + this.jsonWebToken = jsonWebToken; + this.setAuthenticated(false); + } + + public JwtAuthenticationToken(Object principal, Object credentials, + Collection<? extends GrantedAuthority> authorities) { + super(authorities); + this.principal = principal; + this.credentials = credentials; + super.setAuthenticated(true); + } + + public Object getCredentials() { + return credentials; + } + + public Object getPrincipal() { + return this.principal; + } + + public String getJsonWebToken() { + return this.jsonWebToken; + } +} \ No newline at end of file diff --git a/src/main/java/com/moplus/moplus_server/global/security/utils/CookieUtil.java b/src/main/java/com/moplus/moplus_server/global/security/utils/CookieUtil.java new file mode 100644 index 0000000..aa7305e --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/global/security/utils/CookieUtil.java @@ -0,0 +1,20 @@ +package com.moplus.moplus_server.global.security.utils; + +import jakarta.servlet.http.Cookie; +import org.springframework.stereotype.Component; + +@Component +public class CookieUtil { + + public Cookie createCookie(String refreshToken) { + String cookieName = "refreshToken"; + Cookie cookie = new Cookie(cookieName, refreshToken); + + cookie.setHttpOnly(true); + cookie.setSecure(true); + cookie.setPath("/"); + cookie.setMaxAge(60 * 60 * 24 * 7); //일주일 + cookie.setAttribute("SameSite", "None"); + return cookie; + } +} diff --git a/src/main/java/com/moplus/moplus_server/global/security/utils/JwtUtil.java b/src/main/java/com/moplus/moplus_server/global/security/utils/JwtUtil.java new file mode 100644 index 0000000..cc80237 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/global/security/utils/JwtUtil.java @@ -0,0 +1,77 @@ +package com.moplus.moplus_server.global.security.utils; + +import com.moplus.moplus_server.domain.member.domain.Member; +import com.moplus.moplus_server.global.properties.jwt.JwtProperties; +import com.moplus.moplus_server.global.security.token.JwtAuthenticationToken; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.security.Key; +import java.util.Date; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtUtil { + + public static final String TOKEN_ROLE_NAME = "role"; + private final JwtProperties jwtProperties; + + public String generateAccessToken(Member member) { + Date issuedAt = new Date(); + Date expiredAt = new Date(issuedAt.getTime() + jwtProperties.accessTokenExpirationMilliTime()); + return Jwts.builder() + .setIssuer(jwtProperties.issuer()) + .setSubject(member.getId().toString()) + .claim(TOKEN_ROLE_NAME, member.getRole().getValue()) + .setIssuedAt(issuedAt) + .setExpiration(expiredAt) + .signWith(getAccessTokenKey()) + .compact(); + } + + public String generateRefreshToken(Member member) { + Date issuedAt = new Date(); + Date expiredAt = new Date(issuedAt.getTime() + jwtProperties.refreshTokenExpirationMilliTime()); + + return Jwts.builder() + .setIssuer(jwtProperties.issuer()) + .setSubject(member.getId().toString()) + .claim(TOKEN_ROLE_NAME, member.getRole().getValue()) + .setIssuedAt(issuedAt) + .setExpiration(expiredAt) + .signWith(getRefreshTokenKey()) + .compact(); + } + + public Claims getAccessTokenClaims(Authentication authentication) { + + return Jwts.parserBuilder() + .requireIssuer(jwtProperties.issuer()) + .setSigningKey(getAccessTokenKey()) + .build() + .parseClaimsJws(((JwtAuthenticationToken) authentication).getJsonWebToken()) + .getBody(); + } + + public Claims getRefreshTokenClaims(String refreshToken) { + return Jwts.parserBuilder() + .requireIssuer(jwtProperties.issuer()) + .setSigningKey(getRefreshTokenKey()) + .build() + .parseClaimsJws(refreshToken) + .getBody(); + } + + private Key getAccessTokenKey() { + return Keys.hmacShaKeyFor(jwtProperties.accessTokenSecret().getBytes()); + } + + private Key getRefreshTokenKey() { + return Keys.hmacShaKeyFor(jwtProperties.refreshTokenSecret().getBytes()); + } +} diff --git a/src/main/java/com/moplus/moplus_server/global/utils/s3/S3Util.java b/src/main/java/com/moplus/moplus_server/global/utils/s3/S3Util.java index 5e94fb3..68189f1 100644 --- a/src/main/java/com/moplus/moplus_server/global/utils/s3/S3Util.java +++ b/src/main/java/com/moplus/moplus_server/global/utils/s3/S3Util.java @@ -2,21 +2,15 @@ import com.amazonaws.HttpMethod; import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.model.DeleteObjectRequest; import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest; import com.amazonaws.services.s3.model.PutObjectRequest; -import com.moplus.moplus_server.domain.practiceTest.domain.FileExtension; import com.moplus.moplus_server.global.error.exception.ErrorCode; import com.moplus.moplus_server.global.error.exception.NotFoundException; -import com.moplus.moplus_server.global.utils.UUIDGenerator; import java.io.File; -import java.io.IOException; -import java.util.ArrayList; import java.util.Date; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; -import org.springframework.web.multipart.MultipartFile; @Component @RequiredArgsConstructor @@ -38,5 +32,24 @@ public String getS3ObjectUrl(String fileName) { return amazonS3.getUrl(bucketName, fileName).toString(); } + public String getS3PresignedUrl(String fileName, HttpMethod httpMethod) { + + GeneratePresignedUrlRequest generatePresignedUrlRequest = + new GeneratePresignedUrlRequest(bucketName, fileName) + .withMethod(httpMethod) + .withExpiration(getPreSignedUrlExpiration()); + + return amazonS3.generatePresignedUrl(generatePresignedUrlRequest).toString(); + } + + private Date getPreSignedUrlExpiration() { + final int PRESIGNED_EXPIRATION = 1000 * 60 * 30; //30분 + + Date expiration = new Date(); + var expTimeMillis = expiration.getTime(); + expTimeMillis += PRESIGNED_EXPIRATION; + expiration.setTime(expTimeMillis); + return expiration; + } } diff --git a/src/main/java/com/moplus/moplus_server/transactional/TransactionalPracticeService.java b/src/main/java/com/moplus/moplus_server/transactional/TransactionalPracticeService.java deleted file mode 100644 index 801e578..0000000 --- a/src/main/java/com/moplus/moplus_server/transactional/TransactionalPracticeService.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.moplus.moplus_server.transactional; - -import com.moplus.moplus_server.domain.practiceTest.domain.PracticeTest; -import com.moplus.moplus_server.domain.practiceTest.service.client.PracticeTestService; - -public class TransactionalPracticeService { - - private final PracticeTestService practiceTestService; - - public TransactionalPracticeService(PracticeTestService practiceTestService) { - this.practiceTestService = practiceTestService; - } - - public void updateViewCount(Long practiceTestId) { - startTransaction(); - - practiceTestService.updateViewCount(practiceTestId); - - endTransaction(); - } - - private void startTransaction() { - // start transaction - } - - private void endTransaction() { - // end transaction - } -} diff --git a/src/main/resources/application-datasource.yml b/src/main/resources/application-datasource.yml index 2c7cc6b..1876f67 100644 --- a/src/main/resources/application-datasource.yml +++ b/src/main/resources/application-datasource.yml @@ -17,8 +17,3 @@ spring: dialect: org.hibernate.dialect.MySQLDialect default_batch_fetch_size: 1000 -logging: - level: - com.zaxxer.hikari: TRACE - com.zaxxer.hikari.HikariConfig: DEBUG - diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 1055bfb..bd7d998 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -20,6 +20,15 @@ spring: connection-timeout: 3000 # 30 seconds in milliseconds keepalive-time: 600000 # 5 minutes in milliseconds +swagger: + servers: + - url: https://dev.mopl.kr + description: "mopl dev https 서버입니다." + - url: http://dev.mopl.kr + description: "mopl dev http 서버입니다." + - url: http://localhost:8080 + description: "mopl local 서버입니다." + logging: level: - root: ERROR \ No newline at end of file + root: INFO \ No newline at end of file diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 5667c0f..ea4aef2 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -11,6 +11,7 @@ spring: hibernate: ddl-auto: update -logging: - level: - root: ERROR +swagger: + servers: + - url: http://localhost:8080 + description: "mopl local 서버입니다." \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 1f05607..1778d4d 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -15,6 +15,15 @@ spring: connection-timeout: 3000 # 30 seconds in milliseconds keepalive-time: 600000 # 5 minutes in milliseconds +swagger: + servers: + - url: https://prod.mopl.kr + description: "mopl prod https 서버입니다." + - url: http://prod.mopl.kr + description: "mopl prod http 서버입니다." + - url: http://localhost:8080 + description: "mopl local 서버입니다." + logging: level: root: ERROR \ No newline at end of file diff --git a/src/main/resources/application-security.yml b/src/main/resources/application-security.yml new file mode 100644 index 0000000..c7ce091 --- /dev/null +++ b/src/main/resources/application-security.yml @@ -0,0 +1,6 @@ +jwt: + access-token-secret: ${JWT_ACCESS_TOKEN_SECRET:} + refresh-token-secret: ${JWT_REFRESH_TOKEN_SECRET:} + access-token-expiration-time: ${JWT_ACCESS_TOKEN_EXPIRATION_TIME:7200} #2시간 + refresh-token-expiration-time: ${JWT_REFRESH_TOKEN_EXPIRATION_TIME:604800} #7일 + issuer: ${JWT_ISSUER:} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7be66c3..9ef2087 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,10 +2,24 @@ spring: profiles: active: ${profile} group: - local: "local" + local: "local, datasource" dev: "dev" prod: "prod" include: - aws + - security mvc: ignore-default-favicon: true + +cors-allowed-origins: + http://localhost:8080, + https://dev.mopl.kr, + http://dev.mopl.kr, + https://prod.mopl.kr, + http://prod.mopl.kr, + + http://localhost:3000, + http://localhost:5173, + https://www.mopl.kr, + http://www.mopl.kr, + https://mopl-admin.vercel.app \ No newline at end of file diff --git a/src/main/resources/templates/answerInputForm.html b/src/main/resources/templates/answerInputForm.html index 1a0ad45..08e8d99 100644 --- a/src/main/resources/templates/answerInputForm.html +++ b/src/main/resources/templates/answerInputForm.html @@ -1,118 +1,212 @@ <!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>답안 입력 - - + if (totalPoints !== 100) { + alert('배점의 합이 100점이 되어야 합니다.'); + return false; // 폼 제출 중지 + } + return true; // 폼 제출 허용 + } +
-

답안 입력

-
- - - - - - - - - - - - - - - +

답안 입력

+ + +
번호배점 선택선지 선택정답률 입력
- - - - - - - - -
+ + + + + + + + + + + + + + - - + + + + + + + + - - + + - - - - -
번호배점 선택선지 선택정답률 입력
+ + + + + + + + + + + + + + + + + + + + -
- - - - - - - - - - - -
-
- - -
-
+
+ + + + + + + + + + + +
+
+ + +
+
+
+ + + + + + + + + + + +
+
+ + +
+
- - - - - - - - - - - + + + + + + + + + + + - -
- -
+ + + + + + + + +
+ diff --git a/src/main/resources/templates/imageUploadPage.html b/src/main/resources/templates/imageUploadPage.html index a6593ac..d26dcd4 100644 --- a/src/main/resources/templates/imageUploadPage.html +++ b/src/main/resources/templates/imageUploadPage.html @@ -2,9 +2,9 @@ - + 문제 이미지 업로드 - +
@@ -20,22 +20,24 @@

문제 이미지 업로드

- + - 문제 ID - 문제 번호 + 문제 ID + 문제 번호 - 문제 이미지 + 문제 이미지 - 이미지 없음 + 이미지 없음 -
- - - + + + +
@@ -45,7 +47,7 @@

문제 이미지 업로드

- +
diff --git a/src/main/resources/templates/practiceTestList.html b/src/main/resources/templates/practiceTestList.html index b46a001..dab424d 100644 --- a/src/main/resources/templates/practiceTestList.html +++ b/src/main/resources/templates/practiceTestList.html @@ -1,68 +1,68 @@ - - - 모의고사 목록 - - + function confirmDeletion(event) { + event.preventDefault(); + const confirmDelete = confirm("정말 삭제하시겠습니까?"); + if (confirmDelete) { + event.target.closest("form").submit(); + } + } +
- + - + -
    -
  • -
    -
    - 모의고사 이미지 -
    - -
    - - -
    -
    -
  • -
+
    +
  • +
    +
    + 모의고사 이미지 +
    + +
    + + +
    +
    +
  • +
diff --git a/src/main/resources/templates/testInputForm.html b/src/main/resources/templates/testInputForm.html index 1e08913..c49358d 100644 --- a/src/main/resources/templates/testInputForm.html +++ b/src/main/resources/templates/testInputForm.html @@ -1,210 +1,225 @@ - Practice Test 등록 - - + + - /* 표 스타일 */ - table { - width: 100%; - border-collapse: collapse; - margin-bottom: 20px; - } +

Practice Test 등록

- table, th, td { - border: 1px solid #ccc; - } +
+
+ + +
- th, td { - padding: 8px; - text-align: center; - } +
+ + +
- th { - background-color: #f3f3f3; - } - - - +
+ + +
-

Practice Test 등록

+
+ + +
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- -
-
- - - -
+
+ +
+
+ + + +
+
-
- - -
-
- + + +
+
+ +
-
- + diff --git a/src/test/java/com/moplus/moplus_server/domain/TestResult/entity/EstimatedRatingTest.java b/src/test/java/com/moplus/moplus_server/domain/TestResult/entity/EstimatedRatingTest.java index 6855ec4..205d3c8 100644 --- a/src/test/java/com/moplus/moplus_server/domain/TestResult/entity/EstimatedRatingTest.java +++ b/src/test/java/com/moplus/moplus_server/domain/TestResult/entity/EstimatedRatingTest.java @@ -2,11 +2,11 @@ import static org.junit.jupiter.api.Assertions.*; -import com.moplus.moplus_server.domain.practiceTest.domain.RatingRow; -import com.moplus.moplus_server.domain.practiceTest.domain.RatingTable; +import com.moplus.moplus_server.domain.v0.TestResult.entity.EstimatedRating; +import com.moplus.moplus_server.domain.v0.practiceTest.domain.RatingRow; +import com.moplus.moplus_server.domain.v0.practiceTest.domain.RatingTable; import java.util.ArrayList; import java.util.List; -import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; diff --git a/src/test/java/com/moplus/moplus_server/domain/auth/controller/AuthControllerTest.java b/src/test/java/com/moplus/moplus_server/domain/auth/controller/AuthControllerTest.java new file mode 100644 index 0000000..119749a --- /dev/null +++ b/src/test/java/com/moplus/moplus_server/domain/auth/controller/AuthControllerTest.java @@ -0,0 +1,121 @@ +package com.moplus.moplus_server.domain.auth.controller; + +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.moplus.moplus_server.domain.auth.dto.request.AdminLoginRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.WebApplicationContext; + +@Transactional +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("h2test") +@Sql({"/auth-test-data.sql"}) +class AuthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private WebApplicationContext context; + + @BeforeEach + public void setMockMvc() { + this.mockMvc = MockMvcBuilders.webAppContextSetup(context) + .apply(springSecurity()).build(); + } + + @Nested + class 어드민_로그인 { + + @Test + void 성공() throws Exception { + + AdminLoginRequest request = new AdminLoginRequest("admin@example.com", "password123"); + String requestBody = objectMapper.writeValueAsString(request); + + mockMvc.perform(MockMvcRequestBuilders.post("/api/v1/auth/admin/login") + .contentType("application/json") + .content(requestBody)) + .andExpect(status().isOk()) // HTTP 200 응답 확인 + .andExpect(jsonPath("$.data.accessToken").isNotEmpty()) // accessToken 필드 존재 여부 확인 + .andExpect(cookie().exists("refreshToken")) // 리프레시 토큰 쿠키 존재 확인 + .andExpect(cookie().httpOnly("refreshToken", true)) // HTTP Only 설정 확인 + .andExpect(cookie().secure("refreshToken", true)) // Secure 설정 확인 + .andExpect(cookie().path("refreshToken", "/")) // 쿠키 경로 확인 + .andExpect(cookie().attribute("refreshToken", "SameSite", "None")); + } + + + @Test + void 잘못된_요청_본문() throws Exception { + + record TempRecord(String data) { + } + + TempRecord request = new TempRecord("임시 테스트 요청 본문"); + String requestBody = objectMapper.writeValueAsString(request); + + mockMvc.perform(MockMvcRequestBuilders.post("/api/v1/auth/admin/login") + .contentType("application/json") + .content(requestBody)) + .andExpect(status().isUnauthorized()); + } + + @ParameterizedTest + @ValueSource(strings = { + "plainaddress", // 이메일 형식이 아님 + "@missingusername.com", // 사용자명 없음 + "username@.com", // 도메인 이름 없음 + "username@com", // 잘못된 도메인 + "username@domain..com", // 연속된 점 + "username@domain,com", // 쉼표 포함 + "username@domain space.com", // 공백 포함 + "username@domain.com space", // 공백 포함 + "username@domain#com", // 특수문자 포함 + "" // 빈 문자열 + }) + void 잘못된_이메일_양식(String email) throws Exception { + + AdminLoginRequest request = new AdminLoginRequest(email, "password123"); + String requestBody = objectMapper.writeValueAsString(request); + + mockMvc.perform(MockMvcRequestBuilders.post("/api/v1/auth/admin/login") + .contentType("application/json") + .content(requestBody)) + .andExpect(status().isUnauthorized()); // 401 응답 확인 + } + + @Test + void 실패() throws Exception { + + AdminLoginRequest request = new AdminLoginRequest("admin@example.com", "wrong123"); + String requestBody = objectMapper.writeValueAsString(request); + + mockMvc.perform(MockMvcRequestBuilders.post("/api/v1/auth/admin/login") + .contentType("application/json") + .content(requestBody)) + .andExpect(status().isUnauthorized()); // 401 응답 확인 + } + } +} \ No newline at end of file diff --git a/src/test/java/com/moplus/moplus_server/domain/auth/controller/RefreshTokenReissueTest.java b/src/test/java/com/moplus/moplus_server/domain/auth/controller/RefreshTokenReissueTest.java new file mode 100644 index 0000000..6349475 --- /dev/null +++ b/src/test/java/com/moplus/moplus_server/domain/auth/controller/RefreshTokenReissueTest.java @@ -0,0 +1,108 @@ +package com.moplus.moplus_server.domain.auth.controller; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.moplus.moplus_server.global.properties.jwt.JwtProperties; +import com.moplus.moplus_server.global.security.utils.CookieUtil; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import jakarta.servlet.http.Cookie; +import java.security.Key; +import java.util.Date; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("h2test") +@Sql({"/auth-test-data.sql"}) +public class RefreshTokenReissueTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JwtProperties jwtProperties; + + @Autowired + private CookieUtil cookieUtil; + + private String validRefreshToken; + + @BeforeEach + public void setup() { + + // Generate a test token + Key key = Keys.hmacShaKeyFor(jwtProperties.refreshTokenSecret().getBytes()); + Date issuedAt = new Date(); // 3 hour ago + Date expiredAt = new Date(issuedAt.getTime() + jwtProperties.refreshTokenExpirationMilliTime()); + validRefreshToken = Jwts.builder() + .setIssuer(jwtProperties.issuer()) + .setSubject("1") + .claim("role", "ROLE_USER") + .setIssuedAt(issuedAt) + .setExpiration(expiredAt) + .signWith(key) + .compact(); + } + + @Nested + class 토큰_재발급 { + + @Test + void 성공() throws Exception { + // given + Cookie refreshTokenCookie = cookieUtil.createCookie(validRefreshToken); + + // when & then + mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/auth/reissue") + .cookie(refreshTokenCookie)) + .andExpect(status().isOk()) + .andExpect(cookie().exists("refreshToken")) + .andExpect(cookie().httpOnly("refreshToken", true)) + .andExpect(cookie().secure("refreshToken", true)) + .andExpect(cookie().path("refreshToken", "/")) + .andExpect(cookie().attribute("refreshToken", "SameSite", "None")); + } + + @Test + void 실패_리프레시토큰_없음() throws Exception { + // when & then + mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/auth/reissue")) + .andExpect(status().is4xxClientError()); + } + + @Test + void 실패_유효하지_않은_리프레시토큰() throws Exception { + // given + Cookie invalidRefreshTokenCookie = new Cookie("refreshToken", "invalid_refresh_token"); + + // when & then + mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/auth/reissue") + .cookie(invalidRefreshTokenCookie)) + .andExpect(status().isUnauthorized()); + } + + @Test + void 실패_만료된_리프레시토큰() throws Exception { + // given + Cookie expiredRefreshTokenCookie = new Cookie("refreshToken", "expired_refresh_token"); + + // when & then + mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/auth/reissue") + .cookie(expiredRefreshTokenCookie)) + .andExpect(status().isUnauthorized()); + } + } +} diff --git a/src/test/java/com/moplus/moplus_server/domain/member/controller/MemberControllerTest.java b/src/test/java/com/moplus/moplus_server/domain/member/controller/MemberControllerTest.java new file mode 100644 index 0000000..3d3622d --- /dev/null +++ b/src/test/java/com/moplus/moplus_server/domain/member/controller/MemberControllerTest.java @@ -0,0 +1,78 @@ +package com.moplus.moplus_server.domain.member.controller; + +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.moplus.moplus_server.global.properties.jwt.JwtProperties; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.security.Key; +import java.util.Date; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.WebApplicationContext; + +@Transactional +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("h2test") +@Sql({"/auth-test-data.sql"}) +class MemberControllerTest { + + @Nested + class 내정보_가져오기 { + + @Autowired + private JwtProperties jwtProperties; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private WebApplicationContext context; + + private String validToken; + private Key key; + + @BeforeEach + public void setMockMvc() throws Exception { + this.mockMvc = MockMvcBuilders.webAppContextSetup(context) + .apply(springSecurity()).build(); + + key = Keys.hmacShaKeyFor(jwtProperties.accessTokenSecret().getBytes()); + Date issuedAt = new Date(); // 3 hour ago + Date expiredAt = new Date(issuedAt.getTime() + jwtProperties.accessTokenExpirationMilliTime()); + validToken = Jwts.builder() + .setIssuer(jwtProperties.issuer()) + .setSubject("1") + .claim("role", "ROLE_USER") + .setIssuedAt(issuedAt) + .setExpiration(expiredAt) + .signWith(key) + .compact(); + } + + @Test + void 성공() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/member/me") + .contentType("application/json") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + validToken)) + .andExpect(status().isOk()) // 200 응답 확인 + .andExpect(jsonPath("$.data.id").exists()) // MemberGetResponse의 필드 확인 + .andExpect(jsonPath("$.data.name").exists()) + .andExpect(jsonPath("$.data.email").exists()); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/moplus/moplus_server/domain/practiceTest/service/client/OptimisticLockPracticeTestFacadeTest.java b/src/test/java/com/moplus/moplus_server/domain/practiceTest/service/client/OptimisticLockPracticeTestFacadeTest.java deleted file mode 100644 index 487cd63..0000000 --- a/src/test/java/com/moplus/moplus_server/domain/practiceTest/service/client/OptimisticLockPracticeTestFacadeTest.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.moplus.moplus_server.domain.practiceTest.service.client; - -import static org.junit.jupiter.api.Assertions.*; - -import com.moplus.moplus_server.domain.practiceTest.domain.PracticeTest; -import com.moplus.moplus_server.domain.practiceTest.repository.PracticeTestRepository; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; - -@SpringBootTest -@ActiveProfiles("test") -class OptimisticLockPracticeTestFacadeTest { - @Autowired - private OptimisticLockPracticeTestFacade optimisticLockPracticeTestFacade; - - @Autowired - private PracticeTestRepository practiceTestRepository; - - @BeforeEach - void setup() { - PracticeTest practiceTest = new PracticeTest(); - practiceTestRepository.save(practiceTest); - } - - @Test - public void 동시에_조회수가_정상적으로_업데이트_되어야한다() throws InterruptedException { - Long practiceTestId = 1L; - int threadCount = 100; - ExecutorService executorService = Executors.newFixedThreadPool(36); - CountDownLatch countDownLatch = new CountDownLatch(threadCount); - - for (int i = 0; i < threadCount; i++) { - executorService.submit(() -> { - try { - optimisticLockPracticeTestFacade.updateViewCount(practiceTestId); - } catch (InterruptedException e) { - e.printStackTrace(); - } finally { - countDownLatch.countDown(); - } - }); - } - countDownLatch.await(); - - PracticeTest practiceTest = practiceTestRepository.findById(practiceTestId).orElseThrow(); - assertEquals(threadCount, practiceTest.getViewCount()); - } -} \ No newline at end of file diff --git a/src/test/java/com/moplus/moplus_server/domain/practiceTest/service/client/PracticeTestServiceTest.java b/src/test/java/com/moplus/moplus_server/domain/practiceTest/service/client/PracticeTestServiceTest.java deleted file mode 100644 index 092c189..0000000 --- a/src/test/java/com/moplus/moplus_server/domain/practiceTest/service/client/PracticeTestServiceTest.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.moplus.moplus_server.domain.practiceTest.service.client; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.*; - -import com.moplus.moplus_server.domain.practiceTest.domain.PracticeTest; -import com.moplus.moplus_server.domain.practiceTest.domain.Problem; -import com.moplus.moplus_server.domain.practiceTest.repository.PracticeTestRepository; -import com.moplus.moplus_server.domain.practiceTest.repository.ProblemRepository; -import jakarta.transaction.Transactional; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.Callable; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.jdbc.Sql; - -@SpringBootTest -@ActiveProfiles("test") -class PracticeTestServiceTest { - - @Autowired - private PracticeTestService practiceTestService; - - @Autowired - private PracticeTestRepository practiceTestRepository; - @Autowired - private ProblemRepository problemRepository; - - @BeforeEach - void setup() { - PracticeTest practiceTest = new PracticeTest(); - practiceTestRepository.save(practiceTest); - } - - @Test - public void 동시에_조회수가_정상적으로_업데이트_되어야한다() throws InterruptedException { - Long practiceTestId = 1L; - int threadCount = 100; - ExecutorService executorService = Executors.newFixedThreadPool(36); - CountDownLatch countDownLatch = new CountDownLatch(threadCount); - - for (int i = 0; i < threadCount; i++) { - executorService.submit(() -> { - try { - practiceTestService.updateViewCount(practiceTestId); - } finally { - countDownLatch.countDown(); - } - }); - } - countDownLatch.await(); - - PracticeTest practiceTest = practiceTestRepository.findById(practiceTestId).orElseThrow(); - assertEquals(threadCount, practiceTest.getViewCount()); - } - -} \ No newline at end of file diff --git a/src/test/java/com/moplus/moplus_server/domain/practiceTest/service/client/ProblemServiceConcurrencyTest.java b/src/test/java/com/moplus/moplus_server/domain/practiceTest/service/client/ProblemServiceConcurrencyTest.java deleted file mode 100644 index eb9936b..0000000 --- a/src/test/java/com/moplus/moplus_server/domain/practiceTest/service/client/ProblemServiceConcurrencyTest.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.moplus.moplus_server.domain.practiceTest.service.client; - -import com.moplus.moplus_server.domain.practiceTest.domain.PracticeTest; -import com.moplus.moplus_server.domain.practiceTest.domain.Problem; -import com.moplus.moplus_server.domain.practiceTest.dto.client.response.ProblemGetResponse; -import com.moplus.moplus_server.domain.practiceTest.repository.PracticeTestRepository; -import com.moplus.moplus_server.domain.practiceTest.repository.ProblemRepository; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; - -@SpringBootTest -@ActiveProfiles("test") -public class ProblemServiceConcurrencyTest { - - @Autowired - private PracticeTestService practiceTestService; - - @Autowired - private PracticeTestRepository practiceTestRepository; - - @Autowired - private ProblemRepository problemRepository; - - @Autowired - private ProblemService problemService; - - @BeforeEach - void setup() { - PracticeTest practiceTest = new PracticeTest(); - practiceTestRepository.save(practiceTest); - PracticeTest entity = practiceTestRepository.findById(practiceTest.getId()).orElseThrow(); - - Problem problem = Problem.builder() - .problemNumber("1") - .answer("42") - .point(5) - .incorrectNum(10L) - .practiceTest(entity) // Assume we have a PracticeTest entity linked here - .correctRate(0.5) - .build(); - problemRepository.save(problem); - } - - @Test - public void testConcurrentUpdateCorrectRateAndCount() throws InterruptedException { - Long practiceTestId = 1L; - Long problemId = 1L; - int threadCount = 100; - ExecutorService executorService = Executors.newFixedThreadPool(36); - CountDownLatch countDownLatch = new CountDownLatch(threadCount); - Problem problem = problemRepository.findById(1L).orElseThrow(); - - - for (int i = 0; i < threadCount; i++) { - if(i % 2 == 0){ - executorService.submit(() -> { - try { - practiceTestService.updateViewCount(practiceTestId); - } finally { - countDownLatch.countDown(); - } - }); - } else { - executorService.submit(() -> { - try { - problemService.updateCorrectRate(practiceTestId, "1", 0.7); - } finally { - countDownLatch.countDown(); - } - }); - } - } - countDownLatch.await(); - - PracticeTest practiceTest = practiceTestRepository.findById(practiceTestId).orElseThrow(); - - Assertions.assertEquals(threadCount/2, practiceTest.getViewCount()); - } - -} diff --git a/src/test/java/com/moplus/moplus_server/domain/problem/domain/problem/ProblemCustomIdServiceTest.java b/src/test/java/com/moplus/moplus_server/domain/problem/domain/problem/ProblemCustomIdServiceTest.java new file mode 100644 index 0000000..c55b27b --- /dev/null +++ b/src/test/java/com/moplus/moplus_server/domain/problem/domain/problem/ProblemCustomIdServiceTest.java @@ -0,0 +1,83 @@ +package com.moplus.moplus_server.domain.problem.domain.problem; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.atLeast; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.moplus.moplus_server.domain.problem.domain.practiceTest.PracticeTestTag; +import com.moplus.moplus_server.domain.problem.domain.practiceTest.Subject; +import com.moplus.moplus_server.domain.problem.repository.ProblemRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +@ExtendWith(MockitoExtension.class) +class ProblemCustomIdServiceTest { + + public static final String ID_LENGTH = "10"; + @Mock + private ProblemRepository problemRepository; + + @InjectMocks + private ProblemAdminIdService problemAdminIdService; + + private PracticeTestTag practiceTestTag; + + @BeforeEach + void setUp() { + practiceTestTag = Mockito.mock(PracticeTestTag.class); + when(practiceTestTag.getSubject()).thenReturn(Subject.고2); + when(practiceTestTag.getYear()).thenReturn(2024); + when(practiceTestTag.getMonth()).thenReturn(5); + } + + @Test + void nextId_정상생성_및_중복확인() { + // given + int 문제번호 = 20; + ProblemType problemType = ProblemType.GICHUL_PROBLEM; + when(problemRepository.existsByProblemCustomId(any())).thenReturn(false); // 중복 없음 + + // when + ProblemCustomId generatedId = problemAdminIdService.nextId(문제번호, practiceTestTag, problemType); + + // then + assertThat(generatedId).isNotNull(); + assertThat(generatedId.getId()).matches("\\d{" + ID_LENGTH + "}"); // ID 형식이 맞는지 확인 + assertThat(generatedId.getId()).startsWith("12240520"); + + // 문제 ID 중복 확인을 위해 existsById 호출 확인 + verify(problemRepository, atLeastOnce()).existsByProblemCustomId(any()); + + } + + @Test + void nextId_중복발생시_다시_생성() { + // given + int 문제번호 = 2; + ProblemType problemType = ProblemType.GICHUL_PROBLEM; + when(problemRepository.existsByProblemCustomId(any())) + .thenReturn(true) // 첫 번째 생성된 ID는 중복됨 + .thenReturn(false); // 두 번째는 중복 없음 + + // when + ProblemCustomId generatedId = problemAdminIdService.nextId(문제번호, practiceTestTag, problemType); + + // then + assertThat(generatedId).isNotNull(); + assertThat(generatedId.getId()).matches("\\d{" + ID_LENGTH + "}"); + assertThat(generatedId.getId()).startsWith("12240502"); + + // 중복된 ID가 나왔으므로 existsById가 최소 두 번 이상 호출되었는지 확인 + verify(problemRepository, atLeast(2)).existsByProblemCustomId(any()); + } +} \ No newline at end of file diff --git a/src/test/java/com/moplus/moplus_server/domain/problem/repository/ProblemSearchRepositoryCustomTest.java b/src/test/java/com/moplus/moplus_server/domain/problem/repository/ProblemSearchRepositoryCustomTest.java new file mode 100644 index 0000000..b4857b9 --- /dev/null +++ b/src/test/java/com/moplus/moplus_server/domain/problem/repository/ProblemSearchRepositoryCustomTest.java @@ -0,0 +1,73 @@ +package com.moplus.moplus_server.domain.problem.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.moplus.moplus_server.domain.problem.dto.response.ProblemSearchGetResponse; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +@SpringBootTest +@ActiveProfiles("h2test") +@Sql({"/practice-test-tag.sql", "/concept-tag.sql", "/insert-problem.sql"}) +public class ProblemSearchRepositoryCustomTest { + + @Autowired + private ProblemSearchRepositoryCustom problemSearchRepository; + + @Test + void problemId_일부_포함_검색() { + // when + List result = problemSearchRepository.search("12240520", null, null); + + // then + assertThat(result).hasSize(2); + assertThat(result).extracting(ProblemSearchGetResponse::getProblemCustomId) + .containsExactlyInAnyOrder("1224052001", "1224052002"); + } + + @Test + void name_포함_검색() { + // when + List result = problemSearchRepository.search(null, "제목1 ", null); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).getProblemCustomId()).isEqualTo("1224052001"); + } + + @Test + void conceptTagIds_하나라도_포함되면_조회() { + // when + List result = problemSearchRepository.search(null, null, List.of(3L)); + + // then + assertThat(result).hasSize(2); + assertThat(result).extracting(ProblemSearchGetResponse::getProblemCustomId) + .containsExactlyInAnyOrder("1224052001", "1224052002"); + } + + @Test + void problemId_이름_conceptTagIds_모두_적용된_검색() { + // when + List result = problemSearchRepository.search("12240520", "제목1", List.of(1L)); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).getProblemCustomId()).isEqualTo("1224052001"); + } + + @Test + void 아무_조건도_없으면_모든_데이터_조회() { + // when + List result = problemSearchRepository.search(null, null, null); + + // then + assertThat(result).hasSize(2); + } +} \ No newline at end of file diff --git a/src/test/java/com/moplus/moplus_server/domain/problem/service/ProblemSaveServiceTest.java b/src/test/java/com/moplus/moplus_server/domain/problem/service/ProblemSaveServiceTest.java new file mode 100644 index 0000000..e6d5ae2 --- /dev/null +++ b/src/test/java/com/moplus/moplus_server/domain/problem/service/ProblemSaveServiceTest.java @@ -0,0 +1,75 @@ +package com.moplus.moplus_server.domain.problem.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.moplus.moplus_server.domain.problem.domain.problem.Problem; +import com.moplus.moplus_server.domain.problem.domain.problem.ProblemType; +import com.moplus.moplus_server.domain.problem.dto.request.ProblemPostRequest; +import com.moplus.moplus_server.domain.problem.dto.response.ProblemPostResponse; +import com.moplus.moplus_server.domain.problem.repository.ProblemRepository; +import com.moplus.moplus_server.global.error.exception.NotFoundException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +@ActiveProfiles("h2test") +@Sql({"/practice-test-tag.sql", "/concept-tag.sql"}) +@SpringBootTest +class ProblemSaveServiceTest { + + @Autowired + private ProblemSaveService problemSaveService; + + @Autowired + private ProblemRepository problemRepository; + + private ProblemPostRequest problemPostRequest; + + @BeforeEach + void setUp() { + problemPostRequest = new ProblemPostRequest( + ProblemType.GICHUL_PROBLEM, + 1L, + 20 + ); + } + + @Nested + class 문항생성 { + + @Test + void 성공() { + // when + ProblemPostResponse problemResponse = problemSaveService.createProblem(problemPostRequest); + + // then + assertThat(problemResponse).isNotNull(); + + Problem savedProblem = problemRepository.findByIdElseThrow(problemResponse.id()); + assertThat(savedProblem).isNotNull(); + assertThat(savedProblem.getProblemType()).isEqualTo(ProblemType.GICHUL_PROBLEM); + } + + @Test + void 문제_저장_실패_잘못된_부모_문제_ID() { + // given + ProblemPostRequest invalidParentRequest = new ProblemPostRequest( + ProblemType.GICHUL_PROBLEM, + 999L, // 존재하지 않는 parentId 사용 + 10 + ); + + // when & then + assertThatThrownBy(() -> problemSaveService.createProblem(invalidParentRequest)) + .isInstanceOf(NotFoundException.class); + } + } + +} \ No newline at end of file diff --git a/src/test/java/com/moplus/moplus_server/domain/problem/service/ProblemUpdateServiceTest.java b/src/test/java/com/moplus/moplus_server/domain/problem/service/ProblemUpdateServiceTest.java new file mode 100644 index 0000000..6a2fa18 --- /dev/null +++ b/src/test/java/com/moplus/moplus_server/domain/problem/service/ProblemUpdateServiceTest.java @@ -0,0 +1,187 @@ +package com.moplus.moplus_server.domain.problem.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.moplus.moplus_server.domain.problem.domain.childProblem.ChildProblem; +import com.moplus.moplus_server.domain.problem.domain.problem.AnswerType; +import com.moplus.moplus_server.domain.problem.domain.problem.Problem; +import com.moplus.moplus_server.domain.problem.domain.problem.ProblemCustomId; +import com.moplus.moplus_server.domain.problem.domain.problem.ProblemType; +import com.moplus.moplus_server.domain.problem.dto.request.ChildProblemUpdateRequest; +import com.moplus.moplus_server.domain.problem.dto.request.ProblemUpdateRequest; +import com.moplus.moplus_server.domain.problem.dto.response.ProblemGetResponse; +import com.moplus.moplus_server.domain.problem.repository.ProblemRepository; +import com.moplus.moplus_server.global.error.exception.NotFoundException; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +@ActiveProfiles("h2test") +@Sql({"/practice-test-tag.sql", "/concept-tag.sql", "/insert-problem.sql"}) +@SpringBootTest +class ProblemUpdateServiceTest { + + @Autowired + private ProblemUpdateService problemUpdateService; + + @Autowired + private ProblemRepository problemRepository; + + private ProblemCustomId problemCustomId; + private ProblemUpdateRequest problemUpdateRequest; + + @BeforeEach + void setUp() { + problemCustomId = new ProblemCustomId("240520012001"); + + // 기존 자식 문제 업데이트 + ChildProblemUpdateRequest updateChildProblem1 = new ChildProblemUpdateRequest( + 1L, + "updatedChild1.png", + AnswerType.MULTIPLE_CHOICE, + "2", + Set.of(2L, 3L) + ); + + ChildProblemUpdateRequest updateChildProblem2 = new ChildProblemUpdateRequest( + 2L, + "updatedChild2.png", + AnswerType.SHORT_ANSWER, + "23", + Set.of(3L, 4L) + ); + + problemUpdateRequest = new ProblemUpdateRequest( + ProblemType.VARIANT_PROBLEM, + 2L, + 10, + Set.of(1L, 2L, 3L), + "정답", + "업데이트된 제목", + 3, + "업데이트된 메모", + "updatedMainProblem.png", + "updatedMainAnalysis.png", + "updatedMainHandwriting.png", + "updatedReadingTip.png", + "updatedSeniorTip.png", + List.of("prescription1.png", "prescription2.png"), + AnswerType.SHORT_ANSWER, + List.of(updateChildProblem1, updateChildProblem2), + 30, + 45 + ); + } + + @Nested + class 문제_업데이트_정상_동작 { + + @Test + void 문제_업데이트_성공() { + // when + ProblemGetResponse response = problemUpdateService.updateProblem(1L, + problemUpdateRequest); + + // then + assertThat(response).isNotNull(); + assertThat(response.problemCustomId()).startsWith("22230310"); // 문제 ID 확인 + assertThat(response.problemType()).isEqualTo(ProblemType.VARIANT_PROBLEM); + assertThat(response.practiceTestId()).isEqualTo(2L); + assertThat(response.number()).isEqualTo(10); + assertThat(response.conceptTagIds()).containsExactlyInAnyOrderElementsOf(Set.of(1L, 2L, 3L)); + assertThat(response.answer()).isEqualTo("정답"); + assertThat(response.title()).isEqualTo("업데이트된 제목"); + assertThat(response.difficulty()).isEqualTo(3); + assertThat(response.memo()).isEqualTo("업데이트된 메모"); + + // 이미지 URL 검증 + assertThat(response.mainProblemImageUrl()).isEqualTo("updatedMainProblem.png"); + assertThat(response.mainAnalysisImageUrl()).isEqualTo("updatedMainAnalysis.png"); + assertThat(response.readingTipImageUrl()).isEqualTo("updatedReadingTip.png"); + assertThat(response.seniorTipImageUrl()).isEqualTo("updatedSeniorTip.png"); + assertThat(response.mainHandwritingExplanationImageUrl()) + .isEqualTo("updatedMainHandwriting.png"); + assertThat(response.prescriptionImageUrls()) + .containsExactly("prescription1.png", "prescription2.png"); + + // 답안 유형 검증 + assertThat(response.answerType()).isEqualTo(AnswerType.SHORT_ANSWER); + + Problem updatedProblem = problemRepository.findByIdElseThrow(1L); + + // 자식 문제 검증 + List childProblems = updatedProblem.getChildProblems(); + assertThat(childProblems).hasSize(2); + + // 첫 번째 자식 문제 검증 (업데이트된 기존 문제) + ChildProblem updatedChild = childProblems.get(0); + assertThat(updatedChild.getImageUrl()).isEqualTo("updatedChild1.png"); + assertThat(updatedChild.getAnswerType()).isEqualTo(AnswerType.MULTIPLE_CHOICE); + assertThat(updatedChild.getAnswer()).isEqualTo("2"); + assertThat(updatedChild.getConceptTagIds()).containsExactlyInAnyOrderElementsOf(Set.of(2L, 3L)); + + // 두 번째 자식 문제 검증 (새로 추가된 문제) + ChildProblem newChild = childProblems.get(1); + assertThat(newChild.getImageUrl()).isEqualTo("updatedChild2.png"); + assertThat(newChild.getAnswerType()).isEqualTo(AnswerType.SHORT_ANSWER); + assertThat(newChild.getAnswer()).isEqualTo("23"); + assertThat(newChild.getConceptTagIds()).containsExactlyInAnyOrderElementsOf(Set.of(3L, 4L)); + + // 추가된 검증 + assertThat(response.recommendedMinute()).isEqualTo(30); + assertThat(response.recommendedSecond()).isEqualTo(45); + } + } + + @Nested + class 문제_업데이트_예외_처리 { + + @Test + void 문제_업데이트_실패_존재하지_않는_ID() { + // given + String invalidId = "999999999999"; // 존재하지 않는 문제 ID + + // when & then + assertThatThrownBy(() -> problemUpdateService.updateProblem(9999L, problemUpdateRequest)) + .isInstanceOf(NotFoundException.class); + } + + @Test + void 문제_업데이트_실패_잘못된_ConceptTag() { + // given + ProblemUpdateRequest invalidRequest = new ProblemUpdateRequest( + ProblemType.GICHUL_PROBLEM, + 1L, + 20, + Set.of(999L, 1000L), + "정답", + "잘못된 제목", + 3, + "잘못된 메모", + "updatedMainProblem.png", + "updatedMainAnalysis.png", + "updatedMainHandwriting.png", + "updatedReadingTip.png", + "updatedSeniorTip.png", + List.of("prescription1.png"), + AnswerType.SHORT_ANSWER, + List.of(), + 30, + 45 + ); + + // when & then + assertThatThrownBy(() -> problemUpdateService.updateProblem(9999L, invalidRequest)) + .isInstanceOf(NotFoundException.class); + } + } +} diff --git a/src/test/java/com/moplus/moplus_server/domain/problemset/ProblemSetServiceTest.java b/src/test/java/com/moplus/moplus_server/domain/problemset/ProblemSetServiceTest.java new file mode 100644 index 0000000..77fc3ab --- /dev/null +++ b/src/test/java/com/moplus/moplus_server/domain/problemset/ProblemSetServiceTest.java @@ -0,0 +1,175 @@ +package com.moplus.moplus_server.domain.problemset; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +import com.moplus.moplus_server.domain.problemset.domain.ProblemSet; +import com.moplus.moplus_server.domain.problemset.domain.ProblemSetConfirmStatus; +import com.moplus.moplus_server.domain.problemset.dto.request.ProblemReorderRequest; +import com.moplus.moplus_server.domain.problemset.dto.request.ProblemSetUpdateRequest; +import com.moplus.moplus_server.domain.problemset.repository.ProblemSetRepository; +import com.moplus.moplus_server.domain.problemset.service.ProblemSetSaveService; +import com.moplus.moplus_server.domain.problemset.service.ProblemSetUpdateService; +import com.moplus.moplus_server.global.error.exception.ErrorCode; +import com.moplus.moplus_server.global.error.exception.InvalidValueException; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +@ActiveProfiles("h2test") +@Sql({"/insert-problem2.sql"}) +@SpringBootTest +public class ProblemSetServiceTest { + + @Autowired + private ProblemSetSaveService problemSetSaveService; + + @Autowired + private ProblemSetUpdateService problemSetUpdateService; + + @Autowired + private ProblemSetRepository problemSetRepository; + + private ProblemSetUpdateRequest problemSetUpdateRequest; + private Long problemSetId; + + @BeforeEach + void setUp() { + problemSetId = problemSetSaveService.createProblemSet(); + // 초기 문항 세트 생성 요청 데이터 준비 + problemSetUpdateRequest = new ProblemSetUpdateRequest( + "초기 문항세트", + List.of(1L, 2L, 3L) + ); + problemSetUpdateService.updateProblemSet(problemSetId, problemSetUpdateRequest); + + + } + + @Test + void 문항세트_생성_테스트() { + + // then + ProblemSet savedProblemSet = problemSetRepository.findById(problemSetId) + .orElseThrow(() -> new IllegalArgumentException("문항세트를 찾을 수 없습니다.")); + + assertThat(savedProblemSet).isNotNull(); + assertThat(savedProblemSet.getTitle().getValue()).isEqualTo("초기 문항세트"); + assertThat(savedProblemSet.getProblemIds()).hasSize(3); + } + + @Test + void 문항세트_문항순서_변경_테스트() { + // given + + // when + ProblemReorderRequest reorderRequest = new ProblemReorderRequest( + List.of(1L, 2L, 3L) + ); + problemSetUpdateService.reorderProblems(problemSetId, reorderRequest); + + // then + ProblemSet updatedProblemSet = problemSetRepository.findById(problemSetId) + .orElseThrow(() -> new IllegalArgumentException("문항세트를 찾을 수 없습니다.")); + + } + + @Test + void 문항세트_업데이트_테스트() { + + // when + ProblemSetUpdateRequest updateRequest = new ProblemSetUpdateRequest( + "업데이트된 문항세트", + List.of(1L, 2L) + ); + problemSetUpdateService.updateProblemSet(problemSetId, updateRequest); + + // then + ProblemSet updatedProblemSet = problemSetRepository.findByIdElseThrow(problemSetId); + + assertThat(updatedProblemSet.getTitle().getValue()).isEqualTo("업데이트된 문항세트"); + assertThat(updatedProblemSet.getProblemIds()).hasSize(2); + + } + + @Test + void 문항세트_컨펌_토글_테스트() { + + // when + ProblemSetConfirmStatus firstToggleStatus = problemSetUpdateService.toggleConfirmProblemSet( + problemSetId); // CONFIRMED + ProblemSetConfirmStatus secondToggleStatus = problemSetUpdateService.toggleConfirmProblemSet( + problemSetId); // NOT_CONFIRMED + + // then + assertThat(firstToggleStatus).isEqualTo(ProblemSetConfirmStatus.CONFIRMED); // 첫 번째 호출 후 컨펌 상태 확인 + assertThat(secondToggleStatus).isEqualTo(ProblemSetConfirmStatus.NOT_CONFIRMED); // 두 번째 호출 후 비컨펌 상태 확인 + } + + @Test + void 유효하지_않은_문항이_포함된_문항세트_컨펌_실패_테스트() { + + // 유효하지 않은 문항을 포함하도록 설정 (문항 ID가 존재하지 않거나 필수 필드가 누락된 경우) + ProblemSetUpdateRequest invalidUpdateRequest = new ProblemSetUpdateRequest( + "유효하지 않은 문항세트", + List.of(1L, 4L) + ); + problemSetUpdateService.updateProblemSet(problemSetId, invalidUpdateRequest); + + // when & then + assertThatThrownBy(() -> problemSetUpdateService.toggleConfirmProblemSet(problemSetId)) + .isInstanceOf(InvalidValueException.class) + .hasMessageContaining("24052001004번") // 메시지에 포함된 ID 확인 + .hasMessageContaining(ErrorCode.INVALID_CONFIRM_PROBLEM.getMessage()); + } + + @Test + void 빈_제목_문항세트_생성_테스트() { + // given + ProblemSetUpdateRequest emptyTitleRequest = new ProblemSetUpdateRequest( + "", // 빈 문자열 제목 + List.of(1L, 2L, 3L) + ); + + ProblemSetUpdateRequest nullTitleRequest = new ProblemSetUpdateRequest( + null, // null 제목 + List.of(1L, 2L, 3L) + ); + + // when + Long emptyTitleProblemSetId = problemSetSaveService.createProblemSet(); + Long nullTitleProblemSetId = problemSetSaveService.createProblemSet(); + problemSetUpdateService.updateProblemSet(emptyTitleProblemSetId, emptyTitleRequest); + problemSetUpdateService.updateProblemSet(nullTitleProblemSetId, nullTitleRequest); + + // then + ProblemSet emptyTitleSavedProblemSet = problemSetRepository.findByIdElseThrow(emptyTitleProblemSetId); + + ProblemSet nullTitleSavedProblemSet = problemSetRepository.findByIdElseThrow(nullTitleProblemSetId); + + assertThat(emptyTitleSavedProblemSet.getTitle().getValue()).isEqualTo("제목 없음"); // 빈 문자열 제목 테스트 + assertThat(nullTitleSavedProblemSet.getTitle().getValue()).isEqualTo("제목 없음"); // null 제목 테스트 + } + + @Test + void 문항세트_빈_제목_업데이트_테스트() { + // given + Long problemSetId = problemSetSaveService.createProblemSet(); + + ProblemSetUpdateRequest emptyUpdateRequest = new ProblemSetUpdateRequest( + "업데이트된 빈 문항세트", + List.of() + ); + + // when & then + assertThatThrownBy(() -> problemSetUpdateService.updateProblemSet(problemSetId, emptyUpdateRequest)) + .isInstanceOf(InvalidValueException.class) + .hasMessageContaining(ErrorCode.EMPTY_PROBLEMS_ERROR.getMessage()); + } +} \ No newline at end of file diff --git a/src/test/java/com/moplus/moplus_server/domain/problemset/repository/ProblemSetSearchRepositoryCustomTest.java b/src/test/java/com/moplus/moplus_server/domain/problemset/repository/ProblemSetSearchRepositoryCustomTest.java new file mode 100644 index 0000000..8158ee0 --- /dev/null +++ b/src/test/java/com/moplus/moplus_server/domain/problemset/repository/ProblemSetSearchRepositoryCustomTest.java @@ -0,0 +1,146 @@ +package com.moplus.moplus_server.domain.problemset.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.moplus.moplus_server.domain.problemset.domain.ProblemSetConfirmStatus; +import com.moplus.moplus_server.domain.problemset.dto.response.ProblemSetSearchGetResponse; +import com.moplus.moplus_server.domain.problemset.dto.response.ProblemThumbnailResponse; +import com.moplus.moplus_server.domain.problemset.service.ProblemSetUpdateService; +import com.moplus.moplus_server.domain.publish.dto.request.PublishPostRequest; +import com.moplus.moplus_server.domain.publish.service.PublishSaveService; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +@SpringBootTest +@ActiveProfiles("h2test") +@Sql({"/practice-test-tag.sql", "/concept-tag.sql", "/insert-problem.sql", "/insert-problem-set.sql"}) +public class ProblemSetSearchRepositoryCustomTest { + + @Autowired + private ProblemSetSearchRepositoryCustom problemSetSearchRepository; + + @Autowired + private PublishSaveService publishSaveService; + + @Autowired + private ProblemSetUpdateService problemSetUpdateService; + + + @Test + void 문항세트_타이틀_일부_포함_검색() { + // when + List result = problemSetSearchRepository.search("고2 모의고사", null); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).getProblemSetTitle()).isEqualTo("2025년 5월 고2 모의고사 문제 세트"); + } + + @Test + void 문항타이틀_포함_검색() { + // when + List result = problemSetSearchRepository.search(null, "설명"); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(0).getProblemSetTitle()).isEqualTo("2025년 5월 고2 모의고사 문제 세트"); + assertThat(result.get(1).getProblemSetTitle()).isEqualTo("2025년 5월 고3 모의고사 문제 세트"); + } + + @Test + void 모두_적용된_검색() { + // when + List result = problemSetSearchRepository.search("고2", "설명 1"); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).getProblemSetTitle()).isEqualTo("2025년 5월 고2 모의고사 문제 세트"); + } + + @Test + void 아무_조건도_없으면_모든_데이터_조회() { + // when + List result = problemSetSearchRepository.search(null, null); + + // then + assertThat(result).hasSize(2); + } + + @Test + void 문항_여러개_문항세트_검색_조회() { + // when + List result = problemSetSearchRepository.search("고2 모의고사", null); + + // then + assertThat(result).hasSize(1); + ProblemSetSearchGetResponse response = result.get(0); + assertThat(response.getProblemSetTitle()).isEqualTo("2025년 5월 고2 모의고사 문제 세트"); + + // ✅ 문항이 2개 존재하는지 확인 + List problems = response.getProblemThumbnailResponses(); + assertThat(problems).hasSize(2); + + // ✅ 문항의 타이틀, 메모, 이미지 URL이 올바르게 매핑되었는지 확인 + assertThat(problems.get(0).getProblemTitle()).isEqualTo("제목1"); + assertThat(problems.get(0).getProblemMemo()).isEqualTo("기존 문제 설명 1"); + assertThat(problems.get(0).getMainProblemImageUrl()).isEqualTo("mainProblem.png1"); + + assertThat(problems.get(1).getProblemTitle()).isEqualTo("제목2"); + assertThat(problems.get(1).getProblemMemo()).isEqualTo("기존 문제 설명 2"); + assertThat(problems.get(1).getMainProblemImageUrl()).isEqualTo("mainProblem.png2"); + } + + @Test + void 발행되지_않은_문항세트는_NOT_CONFIRMED_테스트() { + // when + List result = problemSetSearchRepository.search("고2 모의고사", null); + + // then + assertThat(result).hasSize(1); + ProblemSetSearchGetResponse response = result.get(0); + + assertThat(response.getConfirmStatus()).isEqualTo(ProblemSetConfirmStatus.NOT_CONFIRMED); + } + + @Test + void 컴펌된_문항세트_검색_테스트() { + // given: CONFIRMED 상태의 문제 세트만 발행 + LocalDate publishDate = LocalDate.now(); + publishSaveService.createPublish(new PublishPostRequest(publishDate.plusDays(5), 2L)); // CONFIRMED 상태 + + // when: publishSearch 실행 (CONFIRMED 상태만 검색되어야 함) + List result = problemSetSearchRepository.confirmSearch( + "고", + "설명" + ); + + // then + assertThat(result).isNotEmpty(); + assertThat(result).allSatisfy(response -> + assertThat(response.getConfirmStatus()).isEqualTo(ProblemSetConfirmStatus.CONFIRMED) + ); + } + + @Test + void 컴펌되지_않은_문항세트_검색_결과없음_테스트() { + // given: 발행되지 않은 문제 세트 존재 + problemSetUpdateService.toggleConfirmProblemSet(2L); + + // when: 발행된 문제 세트만 조회하는 publishSearch 실행 + List result = problemSetSearchRepository.confirmSearch( + null, + null + ); + + // then: CONFIRMED 상태가 없는 경우, 결과가 비어 있어야 함 + assertThat(result).isEmpty(); + } + +} \ No newline at end of file diff --git a/src/test/java/com/moplus/moplus_server/domain/problemset/service/ProblemSetGetServiceTest.java b/src/test/java/com/moplus/moplus_server/domain/problemset/service/ProblemSetGetServiceTest.java new file mode 100644 index 0000000..f148055 --- /dev/null +++ b/src/test/java/com/moplus/moplus_server/domain/problemset/service/ProblemSetGetServiceTest.java @@ -0,0 +1,97 @@ +package com.moplus.moplus_server.domain.problemset.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +import com.moplus.moplus_server.domain.problem.domain.practiceTest.PracticeTestTag; +import com.moplus.moplus_server.domain.problem.repository.PracticeTestTagRepository; +import com.moplus.moplus_server.domain.problem.repository.ProblemRepository; +import com.moplus.moplus_server.domain.problemset.domain.ProblemSet; +import com.moplus.moplus_server.domain.problemset.dto.response.ProblemSetGetResponse; +import com.moplus.moplus_server.domain.problemset.repository.ProblemSetRepository; +import com.moplus.moplus_server.global.error.exception.NotFoundException; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +@ActiveProfiles("h2test") +@Sql({"/practice-test-tag.sql", "/concept-tag.sql", "/insert-problem.sql"}) +@SpringBootTest +public class ProblemSetGetServiceTest { + + @Autowired + private ProblemSetGetService problemSetGetService; + + @Autowired + private ProblemSetRepository problemSetRepository; + + @Autowired + private ProblemRepository problemRepository; + + @Autowired + private PracticeTestTagRepository practiceTestTagRepository; + + private ProblemSet savedProblemSet; + + @BeforeEach + void setUp() { + + // 문항세트 저장 + savedProblemSet = problemSetRepository.save( + new ProblemSet("테스트 문항세트", List.of(1L)) + ); + } + + @Test + void 문항세트_조회_성공_테스트() { + // when + ProblemSetGetResponse response = problemSetGetService.getProblemSet(savedProblemSet.getId()); + + // then + assertThat(response).isNotNull(); + assertThat(response.title()).isEqualTo("테스트 문항세트"); + assertThat(response.problemSummaries()).hasSize(1); + assertThat(response.problemSummaries().get(0).problemCustomId()).isEqualTo("1224052001"); + assertThat(response.problemSummaries().get(0).tagNames()).contains("미분 개념", "적분 개념"); + } + + @Test + void 존재하지_않는_문항세트_조회_실패_테스트() { + // when & then + assertThatThrownBy(() -> problemSetGetService.getProblemSet(999L)) + .isInstanceOf(NotFoundException.class) + .hasMessageContaining("해당 문항세트를 찾을 수 없습니다"); + } + + @Test + void 문항세트_조회_성공_테스트_여러개() { + // given + PracticeTestTag practiceTestTag = practiceTestTagRepository.findByIdElseThrow(1L); + + ProblemSet multipleProblemSet = problemSetRepository.save( + new ProblemSet("여러 문항 테스트 문항세트", List.of(1L, 2L)) + ); + + // when + ProblemSetGetResponse response = problemSetGetService.getProblemSet(multipleProblemSet.getId()); + + // then + assertThat(response).isNotNull(); + assertThat(response.title()).isEqualTo("여러 문항 테스트 문항세트"); + assertThat(response.problemSummaries()).hasSize(2); + + // 첫 번째 문제 검증 + assertThat(response.problemSummaries().get(0).problemCustomId()).isEqualTo("1224052001"); + assertThat(response.problemSummaries().get(0).tagNames()).contains("미분 개념", "적분 개념"); + + // 두 번째 문제 검증 + assertThat(response.problemSummaries().get(1).problemCustomId()).isEqualTo("1224052002"); + assertThat(response.problemSummaries().get(1).tagNames()).contains("미분 개념", "삼각함수 개념"); + } +} \ No newline at end of file diff --git a/src/test/java/com/moplus/moplus_server/domain/publish/service/PublishServiceTest.java b/src/test/java/com/moplus/moplus_server/domain/publish/service/PublishServiceTest.java new file mode 100644 index 0000000..463bf7d --- /dev/null +++ b/src/test/java/com/moplus/moplus_server/domain/publish/service/PublishServiceTest.java @@ -0,0 +1,129 @@ +package com.moplus.moplus_server.domain.publish.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +import com.moplus.moplus_server.domain.publish.domain.Publish; +import com.moplus.moplus_server.domain.publish.dto.request.PublishPostRequest; +import com.moplus.moplus_server.domain.publish.dto.response.PublishMonthGetResponse; +import com.moplus.moplus_server.domain.publish.repository.PublishRepository; +import com.moplus.moplus_server.global.error.exception.ErrorCode; +import com.moplus.moplus_server.global.error.exception.InvalidValueException; +import com.moplus.moplus_server.global.error.exception.NotFoundException; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +@ActiveProfiles("h2test") +@Sql({"/insert-problem.sql", "/insert-problem-set2.sql"}) +@SpringBootTest +public class PublishServiceTest { + + @Autowired + private PublishSaveService publishSaveService; + + @Autowired + private PublishDeleteService publishDeleteService; + + @Autowired + private PublishGetService publishGetService; + + @Autowired + private PublishRepository publishRepository; + + private PublishPostRequest publishPostRequest; + + @BeforeEach + void setUp() { + // 발행 요청 데이터 생성 + publishPostRequest = new PublishPostRequest( + LocalDate.now().plusDays(1), // 내일부터 발행 가능 + 1L + ); + } + + @Test + void 발행_생성_테스트() { + // when + Long publishId = publishSaveService.createPublish(publishPostRequest); + + // then + Publish savedPublish = publishRepository.findByIdElseThrow(publishId); + + assertThat(savedPublish).isNotNull(); + assertThat(savedPublish.getPublishedDate()).isEqualTo(publishPostRequest.publishedDate()); + assertThat(savedPublish.getProblemSetId()).isEqualTo(1L); + } + + @Test + void 발행_삭제_테스트() { + // given + Long publishId = publishSaveService.createPublish(publishPostRequest); + + // when + publishDeleteService.deletePublish(publishId); + + // then + assertThatThrownBy(() -> publishRepository.findByIdElseThrow(publishId)) + .isInstanceOf(NotFoundException.class) + .hasMessageContaining(ErrorCode.PUBLISH_NOT_FOUND.getMessage()); + } + + @Test + void 월별_발행_조회_테스트() { + // given + publishSaveService.createPublish(new PublishPostRequest( + LocalDate.of(2025, 3, 10), + 1L + )); + + publishSaveService.createPublish(new PublishPostRequest( + LocalDate.of(2025, 3, 15), + 1L + )); + + // when + List publishList = publishGetService.getPublishMonth(2025, 3); + + // then + assertThat(publishList).hasSize(2); + assertThat(publishList.get(0).day()).isEqualTo(10); + assertThat(publishList.get(0).problemSetInfo().title()).isEqualTo("2025년 5월 고2 모의고사 문제 세트"); + assertThat(publishList.get(1).day()).isEqualTo(15); + } + + @Test + void 유효하지_않은_월_입력시_예외_테스트() { + // when & then + assertThatThrownBy(() -> publishGetService.getPublishMonth(2025, 13)) + .isInstanceOf(InvalidValueException.class) + .hasMessageContaining(ErrorCode.INVALID_MONTH_ERROR.getMessage()); + + assertThatThrownBy(() -> publishGetService.getPublishMonth(2025, 0)) + .isInstanceOf(InvalidValueException.class) + .hasMessageContaining(ErrorCode.INVALID_MONTH_ERROR.getMessage()); + } + + @Test + void 오늘날짜_또는_과거날짜로_발행_시_예외_테스트() { + // given + LocalDate today = LocalDate.now(); + LocalDate pastDate = today.minusDays(1); + + // when & then (createPublish에서 예외 발생하도록) + assertThatThrownBy(() -> publishSaveService.createPublish(new PublishPostRequest(today, 1L))) + .isInstanceOf(InvalidValueException.class) + .hasMessageContaining(ErrorCode.INVALID_DATE_ERROR.getMessage()); + + assertThatThrownBy(() -> publishSaveService.createPublish(new PublishPostRequest(pastDate, 1L))) + .isInstanceOf(InvalidValueException.class) + .hasMessageContaining(ErrorCode.INVALID_DATE_ERROR.getMessage()); + } +} \ No newline at end of file diff --git a/src/test/java/com/moplus/moplus_server/global/scheduler/TestResultSchedulerTest.java b/src/test/java/com/moplus/moplus_server/global/scheduler/TestResultSchedulerTest.java deleted file mode 100644 index e6b8286..0000000 --- a/src/test/java/com/moplus/moplus_server/global/scheduler/TestResultSchedulerTest.java +++ /dev/null @@ -1,90 +0,0 @@ -package com.moplus.moplus_server.global.scheduler; - -import com.moplus.moplus_server.domain.TestResult.entity.TestResult; -import com.moplus.moplus_server.domain.TestResult.repository.TestResultRepository; -import com.moplus.moplus_server.domain.practiceTest.domain.PracticeTest; -import com.moplus.moplus_server.domain.practiceTest.domain.Subject; -import com.moplus.moplus_server.domain.practiceTest.repository.PracticeTestRepository; -import java.lang.reflect.Field; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; - -import java.time.Duration; -import java.util.List; -import org.mockito.junit.jupiter.MockitoExtension; - -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class TestResultSchedulerTest { - - @Mock - private PracticeTestRepository practiceTestRepository; - - @Mock - private TestResultRepository testResultRepository; - - @InjectMocks - private TestResultScheduler testResultScheduler; // calculateAverageSolvingTime 메서드를 가진 클래스 - - private PracticeTest practiceTest; - private TestResult testResult1; - private TestResult testResult2; - - @BeforeEach - void setUp() throws NoSuchFieldException, IllegalAccessException{ - // PracticeTest 객체 초기화 - practiceTest = PracticeTest.builder() - .name("Sample Test") - .round("1st Round") - .provider("Provider A") - .publicationYear("2024") - .subject(Subject.미적분) - .build(); - Field idField = PracticeTest.class.getDeclaredField("id"); - idField.setAccessible(true); // private 필드 접근 허용 - idField.set(practiceTest, 1L); // - - // TestResult 객체 초기화 (각 테스트의 풀이 시간을 설정) - testResult1 = TestResult.builder() - .score(85) - .solvingTime(Duration.ofMinutes(30)) // 30분 걸림 - .practiceTestId(practiceTest.getId()) - .build(); - practiceTest.plus1SolvesCount(); - - testResult2 = TestResult.builder() - .score(90) - .solvingTime(Duration.ofMinutes(45)) // 45분 걸림 - .practiceTestId(practiceTest.getId()) - .build(); - practiceTest.plus1SolvesCount(); - } - - @Test - void 평균시간계산() { - - when(practiceTestRepository.findAll()).thenReturn(List.of(practiceTest)); - List testResultsForPracticeTest1 = List.of(testResult1, testResult2); - when(testResultRepository.findAllByPracticeTestId(1L)).thenReturn(testResultsForPracticeTest1); - - - // 메서드 실행 - testResultScheduler.calculateAverageSolvingTime(); - - // 검증 - long totalSeconds = testResult1.getSolvingTime().getSeconds() + testResult2.getSolvingTime().getSeconds(); - - long averageSeconds = totalSeconds / 2; - - Duration expectedAverage = Duration.ofSeconds(averageSeconds); - System.out.println(expectedAverage); - - Assertions.assertEquals(practiceTest.getAverageSolvingTime(), expectedAverage); - - } -} \ No newline at end of file diff --git a/src/test/java/com/moplus/moplus_server/global/security/utils/JwtUtilTest.java b/src/test/java/com/moplus/moplus_server/global/security/utils/JwtUtilTest.java new file mode 100644 index 0000000..e96648c --- /dev/null +++ b/src/test/java/com/moplus/moplus_server/global/security/utils/JwtUtilTest.java @@ -0,0 +1,113 @@ +package com.moplus.moplus_server.global.security.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +import com.moplus.moplus_server.global.properties.jwt.JwtProperties; +import com.moplus.moplus_server.global.security.token.JwtAuthenticationToken; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.SignatureException; +import java.security.Key; +import java.util.Date; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.Authentication; + +@ExtendWith(MockitoExtension.class) +public class JwtUtilTest { + + @Mock + private JwtProperties jwtProperties; + + @InjectMocks + private JwtUtil jwtUtil; + + private String validToken; + private Key key; + + @BeforeEach + public void setup() { + // Mock JwtProperties + when(jwtProperties.issuer()).thenReturn("testIssuer"); + when(jwtProperties.accessTokenSecret()).thenReturn( + "mySecretKeymySecretKeymySecretKeymySecretKey"); // 256-bit key + when(jwtProperties.accessTokenExpirationMilliTime()).thenReturn(7200000L); // 1 hour + + // Generate a test token + key = Keys.hmacShaKeyFor(jwtProperties.accessTokenSecret().getBytes()); + Date issuedAt = new Date(); // 3 hour ago + Date expiredAt = new Date(issuedAt.getTime() + jwtProperties.accessTokenExpirationMilliTime()); + validToken = Jwts.builder() + .setIssuer(jwtProperties.issuer()) + .setSubject("1") + .claim("role", "ROLE_USER") + .setIssuedAt(issuedAt) + .setExpiration(expiredAt) + .signWith(key) + .compact(); + } + + @Test + public void 유효한_토큰_통과() { + + Authentication jwtAuthenticationToken = new JwtAuthenticationToken(validToken); + + // Act + Claims claimsJws = jwtUtil.getAccessTokenClaims(jwtAuthenticationToken); + + // Assert + assertNotNull(claimsJws); + assertEquals("testIssuer", claimsJws.getIssuer()); + assertEquals("1", claimsJws.getSubject()); + assertEquals("ROLE_USER", claimsJws.get("role", String.class)); + } + + @Test + public void 만료된_토큰_예외() { + Date issuedAt = new Date(System.currentTimeMillis() - 10800000L); // 3 hour ago + Date expiredAt = new Date(issuedAt.getTime() + jwtProperties.accessTokenExpirationMilliTime()); + String expiredToken = Jwts.builder() + .setIssuer(jwtProperties.issuer()) + .setSubject("12345") + .claim("role", "ROLE_USER") + .setIssuedAt(issuedAt) + .setExpiration(expiredAt) // 1 hour ago + .signWith(key) + .compact(); + + Authentication jwtAuthenticationToken = new JwtAuthenticationToken(expiredToken); + + assertThrows(ExpiredJwtException.class, + () -> jwtUtil.getAccessTokenClaims(jwtAuthenticationToken)); + } + + @Test + public void 조작된_토큰_예외() { + //토큰 변형 + String invalidSignatureToken = validToken.substring(0, validToken.length() - 1) + "@"; + Authentication jwtAuthenticationToken = new JwtAuthenticationToken(invalidSignatureToken); + assertThrows(SignatureException.class, () -> { + jwtUtil.getAccessTokenClaims(jwtAuthenticationToken); + }); + } + + @Test + public void jwt_토큰_형식이_아닌_토큰_예외() { + //형식이 jwt 토큰 형식 조차 아닌 토큰 + String malformedToken = "malformed.token.here"; + Authentication jwtAuthenticationToken = new JwtAuthenticationToken(malformedToken); + assertThrows(MalformedJwtException.class, () -> { + jwtUtil.getAccessTokenClaims(jwtAuthenticationToken); + }); + } +} diff --git a/src/test/resources/application-h2test.yml b/src/test/resources/application-h2test.yml new file mode 100644 index 0000000..10c6a0f --- /dev/null +++ b/src/test/resources/application-h2test.yml @@ -0,0 +1,43 @@ +spring: + jpa: + hibernate: + ddl-auto: create-drop + show-sql: true + properties: + hibernate: + format_sql: true + use_sql_comments: true + highlight_sql: true + + datasource: + url: jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=false;MODE=MYSQL + +jwt: + access-token-secret: 0eFd7h2PLQ5tH7v3jBcXFr6L8hYh5u3g1kFxWrZ0dJc= + refresh-token-secret: q8aV4Mf4r7l5u9OxC7ZtVx2qY2eDz9Tw5uDl9JQ6SJI= + access-token-expiration-time: 3600 #1시간 + refresh-token-expiration-time: 604800 #7일 + issuer: test + +cloud: + aws: + s3: + bucket: test + signature-version: AWS4-HMAC-SHA256 + credentials: + access-key: 0eFd7h2PLQ5tH7v3jBcXFr6L8hYh5u3g1kFxWrZ0dJc= + secret-key: q8aV4Mf4r7l5u9OxC7ZtVx2qY2eDz9Tw5uDl9JQ6SJI= + region: + static: test + auto: false + stack: + auto: false + +cors-allowed-origins: + http://localhost:8080, + http://localhost:3000, + +swagger: + servers: + - url: http://localhost:8080 + description: "mopl local 서버입니다." \ No newline at end of file diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-mysqltest.yml similarity index 100% rename from src/test/resources/application-test.yml rename to src/test/resources/application-mysqltest.yml diff --git a/src/test/resources/auth-test-data.sql b/src/test/resources/auth-test-data.sql new file mode 100644 index 0000000..cb7319e --- /dev/null +++ b/src/test/resources/auth-test-data.sql @@ -0,0 +1,4 @@ +INSERT INTO member (created_at, update_at, member_id, email, password, name, role) +VALUES ('2024-07-24 21:27:20.000000', '2024-07-24 21:27:21.000000', 1, 'admin@example.com', + 'password123', '홍길동', 'ADMIN'); + diff --git a/src/test/resources/concept-tag.sql b/src/test/resources/concept-tag.sql new file mode 100644 index 0000000..3485a8b --- /dev/null +++ b/src/test/resources/concept-tag.sql @@ -0,0 +1,9 @@ +DELETE FROM concept_tag; + +INSERT INTO concept_tag (concept_tag_id, name) +VALUES (1, '미분 개념'), + (2, '적분 개념'), + (3, '삼각함수 개념'), + (4, '행렬 개념'), + (5, '확률과 통계 개념'), + (6, '기하 개념'); \ No newline at end of file diff --git a/src/test/resources/insert-problem-set.sql b/src/test/resources/insert-problem-set.sql new file mode 100644 index 0000000..ffca03e --- /dev/null +++ b/src/test/resources/insert-problem-set.sql @@ -0,0 +1,18 @@ +DELETE +FROM problem_set_problems; +DELETE +FROM problem_set; + +-- 문제 세트 추가 +INSERT INTO problem_set (problem_set_id, title, is_deleted, confirm_status) +VALUES (1, '2025년 5월 고2 모의고사 문제 세트', false, 'NOT_CONFIRMED'); +INSERT INTO problem_set (problem_set_id, title, is_deleted, confirm_status) +VALUES (2, '2025년 5월 고3 모의고사 문제 세트', false, 'CONFIRMED'); + +-- 문제 세트에 포함된 문제 추가 +INSERT INTO problem_set_problems (problem_set_id, problem_id, sequence) +VALUES (1, 1, 0), + (1, 2, 1); +INSERT INTO problem_set_problems (problem_set_id, problem_id, sequence) +VALUES (2, 1, 0), + (2, 2, 1); \ No newline at end of file diff --git a/src/test/resources/insert-problem-set2.sql b/src/test/resources/insert-problem-set2.sql new file mode 100644 index 0000000..ee87ce3 --- /dev/null +++ b/src/test/resources/insert-problem-set2.sql @@ -0,0 +1,11 @@ +DELETE FROM problem_set_problems; +DELETE FROM problem_set; + +-- 문제 세트 추가 +INSERT INTO problem_set (problem_set_id, title, is_deleted, confirm_status) +VALUES (1, '2025년 5월 고2 모의고사 문제 세트', false, 'CONFIRMED'); + +-- 문제 세트에 포함된 문제 추가 +INSERT INTO problem_set_problems (problem_set_id, problem_id, sequence) +VALUES (1, '240520012001', 0), + (1, '240520012002', 1); \ No newline at end of file diff --git a/src/test/resources/insert-problem.sql b/src/test/resources/insert-problem.sql new file mode 100644 index 0000000..1e8006c --- /dev/null +++ b/src/test/resources/insert-problem.sql @@ -0,0 +1,79 @@ +DELETE FROM child_problem_concept; +DELETE FROM problem_concept; +DELETE FROM child_problem; +DELETE FROM problem; + +-- 데이터 삽입 +INSERT INTO problem (problem_id, + problem_custom_id, + practice_test_id, + number, + problem_type, + title, + answer, + difficulty, + memo, + main_problem_image_url, + main_analysis_image_url, + reading_tip_image_url, + senior_tip_image_url, + prescription_image_urls, + answer_type, + is_confirmed, + recommended_minute, + recommended_second) +VALUES (1, '1224052001', 1, 1, 'GICHUL_PROBLEM', '제목1', '1', 5, '기존 문제 설명 1', + 'mainProblem.png1', 'mainAnalysis.png1', 'readingTip.png1', 'seniorTip.png1', + 'prescription.png1', 'MULTIPLE_CHOICE', false, 30, 45), + (2, '1224052002', 1, 1, 'GICHUL_PROBLEM', '제목2', '1', 5, '기존 문제 설명 2', + 'mainProblem.png2', 'mainAnalysis.png2', 'readingTip.png2', 'seniorTip.png2', + 'prescription.png2', 'MULTIPLE_CHOICE', false, 25, 30); + +-- 자식 문제 테이블 생성 +CREATE TABLE IF NOT EXISTS child_problem ( + child_problem_id BIGINT PRIMARY KEY, + problem_id BIGINT, + image_url VARCHAR(255), + answer_type VARCHAR(50), + answer VARCHAR(255), + sequence INT +); + +-- 자식 문제 데이터 삽입 +INSERT INTO child_problem (child_problem_id, + problem_id, + image_url, + answer_type, + answer, + sequence) +VALUES (1, 1, 'child1.png', 'MULTIPLE_CHOICE', '1', 0), + (2, 1, 'child2.png', 'SHORT_ANSWER', '정답2', 1); + +-- 문제-컨셉 태그 연결 테이블 생성 +CREATE TABLE IF NOT EXISTS problem_concept ( + problem_id BIGINT, + concept_tag_id BIGINT, + PRIMARY KEY (problem_id, concept_tag_id) +); + +-- 문제-컨셉 태그 데이터 삽입 +INSERT INTO problem_concept (problem_id, concept_tag_id) +VALUES (1, 1), + (1, 2), + (1, 3), + (2, 1), + (2, 3); + +-- 자식 문제-컨셉 태그 연결 테이블 생성 +CREATE TABLE IF NOT EXISTS child_problem_concept ( + child_problem_id BIGINT, + concept_tag_id BIGINT, + PRIMARY KEY (child_problem_id, concept_tag_id) +); + +-- 자식 문제-컨셉 태그 데이터 삽입 +INSERT INTO child_problem_concept (child_problem_id, concept_tag_id) +VALUES (1, 3), + (1, 4), + (2, 5), + (2, 6); \ No newline at end of file diff --git a/src/test/resources/insert-problem2.sql b/src/test/resources/insert-problem2.sql new file mode 100644 index 0000000..28340d6 --- /dev/null +++ b/src/test/resources/insert-problem2.sql @@ -0,0 +1,100 @@ +DELETE +FROM child_problem_concept; +DELETE +FROM problem_concept; +DELETE +FROM child_problem; +DELETE +FROM problem; + +-- problem 데이터 삽입 +INSERT INTO problem (problem_id, + problem_custom_id, + practice_test_id, + number, + problem_type, + title, + answer, + difficulty, + memo, + main_problem_image_url, + main_analysis_image_url, + main_handwriting_explanation_image_url, + reading_tip_image_url, + senior_tip_image_url, + prescription_image_urls, + answer_type, + is_confirmed, + recommended_minute, + recommended_second) +VALUES (1, '24052001001', 1, 1, 'GICHUL_PROBLEM', '제목1', '1', 5, '기존 문제 설명', + 'mainProblem.png', 'mainAnalysis.png', 'mainHandwriting1.png', 'readingTip.png', 'seniorTip.png', + 'prescription1.png, prescription2.png', 'MULTIPLE_CHOICE', false, + 30, 0), + + (2, '24052001002', 1, 2, 'GICHUL_PROBLEM', '제목2', '2', 4, '문제 2 설명', + 'mainProblem2.png', 'mainAnalysis2.png', 'mainHandwriting2.png', 'readingTip2.png', 'seniorTip2.png', + 'prescription3.png, prescription4.png', 'MULTIPLE_CHOICE', false, + 20, 30), + + (3, '24052001003', 1, 3, 'GICHUL_PROBLEM', '제목3', '3', 3, '문제 3 설명', + 'mainProblem3.png', 'mainAnalysis3.png', 'mainHandwriting3.png', 'readingTip3.png', 'seniorTip3.png', + 'prescription5.png, prescription6.png', 'SHORT_ANSWER', true, + 15, 45); + +-- 자식 문제 데이터 삽입 +INSERT INTO child_problem (child_problem_id, + problem_id, + image_url, + answer_type, + answer, + sequence) +VALUES (1, 1, 'child1.png', 'MULTIPLE_CHOICE', '1', 0), + (2, 1, 'child2.png', 'SHORT_ANSWER', '정답2', 1), + (3, 2, 'child3.png', 'MULTIPLE_CHOICE', '2', 0), + (4, 3, 'child4.png', 'SHORT_ANSWER', '3', 0); + +-- 문제-컨셉 태그 연결 +INSERT INTO problem_concept (problem_id, concept_tag_id) +VALUES (1, 1), + (1, 2), + (2, 2), + (2, 3), + (3, 3), + (3, 4); + +-- 자식 문제-컨셉 태그 연결 +INSERT INTO child_problem_concept (child_problem_id, concept_tag_id) +VALUES (1, 1), + (1, 2), + (2, 2), + (2, 3), + (3, 3), + (3, 4), + (4, 1), + (4, 4); + +-- 유효하지 않은 문제 데이터 삽입 +INSERT INTO problem (problem_id, + problem_custom_id, + practice_test_id, + number, + problem_type, + title, + answer, + difficulty, + memo, + main_problem_image_url, + main_analysis_image_url, + main_handwriting_explanation_image_url, + reading_tip_image_url, + senior_tip_image_url, + prescription_image_urls, + answer_type, + is_confirmed, + recommended_minute, + recommended_second) +VALUES (4, '24052001004', 1, 4, 'GICHUL_PROBLEM', '', '', 1, '유효하지 않은 문제 설명', + '', 'mainAnalysis4.png', '', 'readingTip4.png', 'seniorTip4.png', + '', 'MULTIPLE_CHOICE', false, + null, null); \ No newline at end of file diff --git a/src/test/resources/practice-test-tag.sql b/src/test/resources/practice-test-tag.sql new file mode 100644 index 0000000..60c45c7 --- /dev/null +++ b/src/test/resources/practice-test-tag.sql @@ -0,0 +1,5 @@ +DELETE FROM practice_test_tag; + +INSERT INTO practice_test_tag (practice_test_tag_id, name, test_year, test_month, subject, area) +VALUES (1, '2025년 5월 고2 모의고사', 2024, 5, '고2', '수학'), + (2, '2023년 3월 고2 모의고사', 2023, 3, '고2', '수학'); \ No newline at end of file