diff --git a/.github/ISSUE_TEMPLATE/issue_template.md b/.github/ISSUE_TEMPLATE/issue_template.md new file mode 100644 index 00000000..eef46991 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue_template.md @@ -0,0 +1,22 @@ +--- +name: Issue Template +about: Sopetit Issue Template +title: "" +labels: '' +assignees: '' + +--- + +[comment]: <> (priority 와 task size를 뱃지로 정해주세요) + +**📌 상세 설명** + +[comment]: <> (이슈에 대한 설명을 적어주세요) + +**📝 체크리스트** + +[comment]: <> (해야 할 일들을 상세히 나눠 적어주시면 좋아요) + +- [ ] + +- [ ] \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..971ff5e1 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,8 @@ +## ✨ Related Issue +- close #이슈번호 +
+ +## 📝 기능 구현 명세 +- 이곳에는 postman 테스트 결과를 넣어주세요 + +## 🐥 추가적인 언급 사항 diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml new file mode 100644 index 00000000..4cfc81cb --- /dev/null +++ b/.github/workflows/CI.yml @@ -0,0 +1,32 @@ +name: CI + +on: + pull_request: + branches: [ "develop" ] + +jobs: + build: + runs-on: ubuntu-22.04 + + steps: + - name: 체크아웃 + uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: 'corretto' + java-version: '17' + + - name: application.yaml 생성 + run: | + cd src/main/resources + echo "${{ secrets.APPLICATION_SECRET_YML }}" > ./application-secret.yml + working-directory: ${{ env.working-directory }} + + - name: 빌드 + run: | + chmod +x gradlew + ./gradlew build -x test + working-directory: ${{ env.working-directory }} + shell: bash \ No newline at end of file diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml new file mode 100644 index 00000000..f23aacfb --- /dev/null +++ b/.github/workflows/deploy-prod.yml @@ -0,0 +1,60 @@ +name: deploy + +on: + push: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-22.04 + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: 17 + distribution: 'temurin' + cache: gradle + + - name: Create application-secret.yml + run: | + pwd + touch src/main/resources/application-secret.yml + echo "${{ secrets.APPLICATION_SECRET_YML }}" >> src/main/resources/application-secret.yml + cat src/main/resources/application-secret.yml + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v1-node16 + with: + aws-access-key-id: ${{ secrets.ACCESS_KEY_ID_PROD }} + aws-secret-access-key: ${{ secrets.ACCESS_KEY_SECRET_PROD }} + aws-region: ap-northeast-2 + + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + shell: bash + + - name: Build with Gradle + run: ./gradlew build + shell: bash + + - name: Make zip file + run: zip -qq -r ./$GITHUB_SHA.zip . + shell: bash + + - name: Upload to AWS S3 + run: | + aws deploy push \ + --application-name softie-code-deploy \ + --ignore-hidden-files \ + --s3-location s3://${{ secrets.AWS_BUCKET_NAME_PROD }}/$GITHUB_SHA.zip \ + --source . + + - name: Code Deploy + run: aws deploy create-deployment --application-name softie-code-deploy + --deployment-config-name CodeDeployDefault.AllAtOnce + --deployment-group-name prod-group + --s3-location bucket=${{ secrets.AWS_BUCKET_NAME_PROD }},bundleType=zip,key=$GITHUB_SHA.zip \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..558fc777 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,60 @@ +name: deploy + +on: + push: + branches: [ develop ] + +jobs: + build: + runs-on: ubuntu-22.04 + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: 17 + distribution: 'temurin' + cache: gradle + + - name: Create application-secret.yml + run: | + pwd + touch src/main/resources/application-secret.yml + echo "${{ secrets.APPLICATION_SECRET_YML }}" >> src/main/resources/application-secret.yml + cat src/main/resources/application-secret.yml + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v1-node16 + with: + aws-access-key-id: ${{ secrets.ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.ACCESS_KEY_SECRET }} + aws-region: ap-northeast-2 + + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + shell: bash + + - name: Build with Gradle + run: ./gradlew build + shell: bash + + - name: Make zip file + run: zip -qq -r ./$GITHUB_SHA.zip . + shell: bash + + - name: Upload to AWS S3 + run: | + aws deploy push \ + --application-name sopetit-codedeploy \ + --ignore-hidden-files \ + --s3-location s3://${{ secrets.AWS_BUCKET_NAME }}/$GITHUB_SHA.zip \ + --source . + + - name: Code Deploy + run: aws deploy create-deployment --application-name sopetit-codedeploy + --deployment-config-name CodeDeployDefault.AllAtOnce + --deployment-group-name sopetit-group + --s3-location bucket=${{ secrets.AWS_BUCKET_NAME }},bundleType=zip,key=$GITHUB_SHA.zip \ No newline at end of file diff --git a/.gitignore b/.gitignore index c2065bc2..89562085 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ out/ ### VS Code ### .vscode/ + +### configuration ### +application-secret.yml \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..198514ee --- /dev/null +++ b/README.md @@ -0,0 +1,137 @@ +### 📑 Architecture +![image](https://github.com/Team-Sopetit/Sopetit-server/assets/80771842/fc4c347d-03fd-4298-a097-3110d36c92ad) + +### 📋 Model Diagram +![image](https://github.com/Team-Sopetit/Sopetit-server/assets/80771842/a260dcff-292b-4dd1-8acd-030a57a57a65) + +### 📖 Directory +``` +📁 Sopetit-server +├── .github +├── .idea +├── build +├── gen +├── gradel +├── scripts +├── src.main +│ ├──java.com.soptie.server +│   ├── auth +│   ├── common +│   ├── conversation +│   ├── doll +│   ├── member +│   ├── controller +│   ├── dto +│   ├── entity +│   ├── message +│   ├── repository +│   ├── service +│   ├── memberDoll +│   ├── memberRoutine +│   ├── routine +│   ├── test +├── src.test +│ ├──java.com.soptie.server +│   ├── auth +│   ├── base +│   ├── doll +│   ├── member +│   ├── memberRoutine +│   ├── controller +│   ├── fixture +│   ├── routine +│   ├── test +``` + +### ✉️ Commit Messge Rules + +**서버** 들의 **Git Commit Message Rules** + +- 반영사항을 바로 확인할 수 있도록 작은 기능 하나라도 구현되면 커밋을 권장합니다. +- 기능 구현이 완벽하지 않을 땐, 각자 브랜치에 커밋을 해주세요. + +### 📌 Commit Convention + +**[태그] 제목의 형태** + +| 태그 이름 | 설명 | +| :-------: | :-----------------------------------------------: | +| FEAT | 새로운 기능을 추가할 경우 | +| FIX | 버그를 고친 경우 | +| CHORE | 짜잘한 수정 | +| DOCS | 문서 수정 | +| INIT | 초기 설정 | +| TEST | 테스트 코드, 리펙토링 테스트 코드 추가 | +| RENAME | 파일 혹은 폴더명을 수정하거나 옮기는 작업인 경우 | +| STYLE | 코드 포맷팅, 세미콜론 누락, 코드 변경이 없는 경우 | +| REFACTOR | 코드 리팩토링 | + +### **커밋 타입** + +- `[태그] 설명` 형식으로 커밋 메시지를 작성합니다. +- 태그는 영어를 쓰고 대문자로 작성합니다. + +예시 > + +``` + [FEAT] 검색 api 추가 +``` + +### **💻 Github mangement** + +**소프티** 들의 WorkFlow : **Gitflow Workflow** + +- Develop, Feature, Hotfix 브랜치 + +- 개발(develop): 기능들의 통합 브랜치 + +- 기능 단위 개발(feature): 기능 단위 브랜치 + +- 버그 수정 및 갑작스런 수정(hotfix): 수정 사항 발생 시 브랜치 + +- 개발 브랜치 아래 기능별 브랜치를 만들어 작성합니다. + +### ✍🏻 Code Convention + +[에어비앤비 코드 컨벤션](https://github.com/airbnb/javascript) + +### 📍 Gitflow 규칙 + +- Develop에 직접적인 commit, push는 금지합니다. +- 커밋 메세지는 다른 사람들이 봐도 이해할 수 있게 써주세요. +- 작업 이전에 issue 작성 후 pullrequest 와 issue를 연동해 주세요. +- 풀리퀘스트를 통해 코드 리뷰를 전원이 코드리뷰를 진행합니다. +- 기능 개발 시 개발 브랜치에서 feature/기능 으로 브랜치를 파서 관리합니다. +- feature 자세한 기능 한 가지를 담당하며, 기능 개발이 완료되면 각자의 브랜치로 Pull Request를 보냅니다. +- 각자가 기간 동안 맡은 역할을 전부 수행하면, 각자 브랜치에서 develop브랜치로 Pull Request를 보냅니다. + **develop 브랜치로의 Pull Request는 상대방의 코드리뷰 후에 merge할 수 있습니다.** + +### ❗️ branch naming convention + +- develop +- feature/issue_number-도메인-http Method-api +- fix/issue_number-도메인-http Method-api +- release/version_number +- hotfix/issue_number - Short Description + +예시 > + +``` + feature/#3-user-post-api +``` + +### 📋 Code Review Convention + +- P1: 꼭 반영해주세요 (Request changes) +- P2: 적극적으로 고려해주세요 (Request changes) +- P3: 웬만하면 반영해 주세요 (Comment) +- P4: 반영해도 좋고 넘어가도 좋습니다 (Approve) +- P5: 그냥 사소한 의견입니다 (Approve) + +### 👩‍👧‍👧 Our Team + +| **🍀 [최승빈](https://github.com/csb9427)** | **🍀 [남궁찬](https://github.com/Chan531)** |**🍀 [김소현](https://github.com/thguss)** | + |:-----------------------------------:|:-----------------------------------:|:-----------------------------------:| +| Server Developer | Server Developer | Server Developer | +| ![image](https://github.com/Team-Sopetit/Sopetit-server/assets/80771842/4eaa9aaa-b834-4883-91c8-cb5dd3005c5d) | ![image](https://github.com/Team-Sopetit/Sopetit-server/assets/80771842/3e82a81c-1710-4199-8c5c-c920fdb8229b) | ![image](https://github.com/Team-Sopetit/Sopetit-server/assets/80771842/ca9420e7-744d-4725-a9d9-36f79669fd04) | +| 프로젝트 세팅
| 프로젝트 셋팅
| 프로젝트 세팅
| diff --git a/appspec.yml b/appspec.yml new file mode 100644 index 00000000..09ed6224 --- /dev/null +++ b/appspec.yml @@ -0,0 +1,19 @@ +version: 0.0 +os: linux + +files: + - source: / + destination: /home/ubuntu/build + overwrite: yes + +permissions: + - object: /home/ubuntu + pattern: '**' + owner: ubuntu + group: ubuntu + +hooks: + AfterInstall: + - location: scripts/deploy.sh + timeout: 1000 + runas: ubuntu \ No newline at end of file diff --git a/build.gradle b/build.gradle index 08753b73..4d65d1a8 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,19 @@ +import org.hidetake.gradle.swagger.generator.GenerateSwaggerUI + plugins { id 'java' id 'org.springframework.boot' version '3.2.1' id 'io.spring.dependency-management' version '1.1.4' + + // restdocs-swagger + id 'com.epages.restdocs-api-spec' version '0.18.2' + id 'org.hidetake.swagger.generator' version '2.18.2' +} + +swaggerSources { + sample { + setInputFile(file("${project.buildDir}/api-spec/openapi3.yaml")) + } } group = 'com.soptie' @@ -25,14 +37,61 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.h2database:h2' - runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' + + // https://mvnrepository.com/artifact/org.postgresql/postgresql + implementation 'org.postgresql:postgresql:42.7.1' + + // jwt + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' + implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // gson + implementation 'com.google.code.gson:gson:2.8.6' + + // restdocs-swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' + testImplementation 'com.epages:restdocs-api-spec-mockmvc:0.18.2' + testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' + + // queryDSL + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" + + // slack logback + implementation 'com.github.maricn:logback-slack-appender:1.4.0' } tasks.named('test') { useJUnitPlatform() } + +tasks.withType(GenerateSwaggerUI).configureEach { + dependsOn 'openapi3' + copy { + from "build/resources/main/static/docs" + into "src/main/resources/static/docs/" + } +} + +bootJar { + dependsOn(':openapi3') +} + +openapi3 { + server = "http://localhost:8080" + title = "소프티 API" + description = "소프티 API 명세서" + version = "0.0.1" + outputFileNamePrefix = 'open-api-3.0.1' + format = 'json' + outputDirectory = 'build/resources/main/static/docs' +} diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 00000000..daacb9f6 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,29 @@ +REPOSITORY=/home/ubuntu/build +cd $REPOSITORY + +APP_NAME=server-0.0.1-SNAPSHOT.jar +JAR_NAME=$(ls $REPOSITORY/build/libs/ | grep '.jar' | tail -n 1) +JAR_PATH=$REPOSITORY/build/libs/$JAR_NAME + +CURRENT_PID=$(pgrep -f $APP_NAME) + +if [ -z $CURRENT_PID ] +then + echo "> 현재 구동중인 애플리케이션이 없으므로 종료하지 않습니다." +else + echo "> kill -15 $CURRENT_PID" + sudo kill -15 $CURRENT_PID + sleep 5 +fi + +echo "> $JAR_PATH 배포" + +if [ "$DEPLOYMENT_GROUP_NAME" == "sopetit-group" ] +then + nohup java -jar -Dspring.profiles.active=dev /home/ubuntu/build/build/libs/server-0.0.1-SNAPSHOT.jar > /dev/null 2> /dev/null < /dev/null & +fi + +if [ "$DEPLOYMENT_GROUP_NAME" == "prod-group" ] +then + nohup java -jar -Dspring.profiles.active=prod /home/ubuntu/build/build/libs/server-0.0.1-SNAPSHOT.jar > /dev/null 2> /dev/null < /dev/null & +fi \ No newline at end of file diff --git a/src/main/java/com/soptie/server/auth/controller/AuthController.java b/src/main/java/com/soptie/server/auth/controller/AuthController.java new file mode 100644 index 00000000..58cd8720 --- /dev/null +++ b/src/main/java/com/soptie/server/auth/controller/AuthController.java @@ -0,0 +1,48 @@ +package com.soptie.server.auth.controller; + +import com.soptie.server.auth.dto.SignInRequest; +import com.soptie.server.auth.service.AuthService; +import com.soptie.server.common.dto.Response; +import lombok.RequiredArgsConstructor; +import lombok.val; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.security.Principal; + +import static com.soptie.server.auth.message.SuccessMessage.*; +import static com.soptie.server.common.dto.Response.success; + +@RestController +@RequiredArgsConstructor +@RequestMapping("api/v1/auth") +public class AuthController { + + private final AuthService authService; + + @PostMapping + public ResponseEntity signIn(@RequestHeader("Authorization") String socialAccessToken, @RequestBody SignInRequest request) { + val response = authService.signIn(socialAccessToken, request); + return ResponseEntity.ok(success(SUCCESS_SIGN_IN.getMessage(), response)); + } + + @PostMapping("/token") + public ResponseEntity reissueToken(@RequestHeader("Authorization") String refreshToken) { + val response = authService.reissueToken(refreshToken); + return ResponseEntity.ok(success(SUCCESS_RECREATE_TOKEN.getMessage(), response)); + } + + @PostMapping("/logout") + public ResponseEntity signOut(Principal principal) { + val memberId = Long.parseLong(principal.getName()); + authService.signOut(memberId); + return ResponseEntity.ok(success(SUCCESS_SIGN_OUT.getMessage())); + } + + @DeleteMapping + public ResponseEntity withdrawal(Principal principal) { + val memberId = Long.parseLong(principal.getName()); + authService.withdraw(memberId); + return ResponseEntity.ok(success(SUCCESS_WITHDRAWAL.getMessage())); + } +} diff --git a/src/main/java/com/soptie/server/auth/dto/SignInRequest.java b/src/main/java/com/soptie/server/auth/dto/SignInRequest.java new file mode 100644 index 00000000..872d9a0c --- /dev/null +++ b/src/main/java/com/soptie/server/auth/dto/SignInRequest.java @@ -0,0 +1,14 @@ +package com.soptie.server.auth.dto; + +import com.soptie.server.member.entity.SocialType; + +import lombok.NonNull; + +public record SignInRequest( + @NonNull SocialType socialType +) { + + public static SignInRequest of(SocialType socialType) { + return new SignInRequest(socialType); + } +} diff --git a/src/main/java/com/soptie/server/auth/dto/SignInResponse.java b/src/main/java/com/soptie/server/auth/dto/SignInResponse.java new file mode 100644 index 00000000..7ab612e8 --- /dev/null +++ b/src/main/java/com/soptie/server/auth/dto/SignInResponse.java @@ -0,0 +1,21 @@ +package com.soptie.server.auth.dto; + +import com.soptie.server.auth.vo.Token; +import lombok.Builder; +import lombok.NonNull; + +@Builder +public record SignInResponse( + @NonNull String accessToken, + @NonNull String refreshToken, + boolean isMemberDollExist +) { + + public static SignInResponse of(Token token, boolean isMemberDollExist) { + return SignInResponse.builder() + .accessToken(token.getAccessToken()) + .refreshToken(token.getRefreshToken()) + .isMemberDollExist(isMemberDollExist) + .build(); + } +} diff --git a/src/main/java/com/soptie/server/auth/dto/TokenResponse.java b/src/main/java/com/soptie/server/auth/dto/TokenResponse.java new file mode 100644 index 00000000..ad715a6a --- /dev/null +++ b/src/main/java/com/soptie/server/auth/dto/TokenResponse.java @@ -0,0 +1,16 @@ +package com.soptie.server.auth.dto; + +import lombok.Builder; +import lombok.NonNull; + +@Builder +public record TokenResponse( + @NonNull String accessToken +) { + + public static TokenResponse of(String accessToken) { + return TokenResponse.builder() + .accessToken(accessToken) + .build(); + } +} diff --git a/src/main/java/com/soptie/server/auth/exception/AuthException.java b/src/main/java/com/soptie/server/auth/exception/AuthException.java new file mode 100644 index 00000000..189342c2 --- /dev/null +++ b/src/main/java/com/soptie/server/auth/exception/AuthException.java @@ -0,0 +1,16 @@ +package com.soptie.server.auth.exception; + +import com.soptie.server.auth.message.ErrorCode; + +import lombok.Getter; + +@Getter +public class AuthException extends RuntimeException { + + private final ErrorCode errorCode; + + public AuthException(ErrorCode errorCode) { + super("[AuthException] : " + errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/soptie/server/auth/jwt/CustomJwtAuthenticationEntryPoint.java b/src/main/java/com/soptie/server/auth/jwt/CustomJwtAuthenticationEntryPoint.java new file mode 100644 index 00000000..581d364e --- /dev/null +++ b/src/main/java/com/soptie/server/auth/jwt/CustomJwtAuthenticationEntryPoint.java @@ -0,0 +1,35 @@ +package com.soptie.server.auth.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.soptie.server.common.dto.Response; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +import static com.soptie.server.auth.message.ErrorCode.*; +import static jakarta.servlet.http.HttpServletResponse.SC_UNAUTHORIZED; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +@Component +@RequiredArgsConstructor +public class CustomJwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { + setResponse(response); + } + + private void setResponse(HttpServletResponse response) throws IOException { + response.setCharacterEncoding("UTF-8"); + response.setContentType(APPLICATION_JSON_VALUE); + response.setStatus(SC_UNAUTHORIZED); + response.getWriter().println(objectMapper.writeValueAsString(Response.fail(INVALID_TOKEN.getMessage()))); + } +} diff --git a/src/main/java/com/soptie/server/auth/jwt/JwtAuthenticationFilter.java b/src/main/java/com/soptie/server/auth/jwt/JwtAuthenticationFilter.java new file mode 100644 index 00000000..e05c9eb8 --- /dev/null +++ b/src/main/java/com/soptie/server/auth/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,69 @@ +package com.soptie.server.auth.jwt; + +import com.soptie.server.auth.exception.AuthException; +import com.soptie.server.common.config.ValueConfig; + +import io.jsonwebtoken.ExpiredJwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import lombok.val; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +import static com.soptie.server.auth.jwt.JwtValidationType.VALID_JWT; +import static io.jsonwebtoken.lang.Strings.hasText; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + private final ValueConfig valueConfig; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + try { + val token = getAccessTokenFromRequest(request); + if (hasText(token) && jwtTokenProvider.validateToken(token) == VALID_JWT) { + val authentication = new UserAuthentication(getMemberId(token), null, null); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (Exception exception) { + log.error(exception.getMessage()); + } + + filterChain.doFilter(request, response); + } + + private long getMemberId(String token) { + return jwtTokenProvider.getUserFromJwt(token); + } + + private String getAccessTokenFromRequest(HttpServletRequest request) { + return isContainsAccessToken(request) ? getAuthorizationAccessToken(request) : null; + } + + private boolean isContainsAccessToken(HttpServletRequest request) { + val authorization = request.getHeader(AUTHORIZATION); + return authorization != null && authorization.startsWith(valueConfig.getBEARER_HEADER()); + } + + private String getAuthorizationAccessToken(HttpServletRequest request) { + return getTokenFromBearerString(request.getHeader(AUTHORIZATION)); + } + + private String getTokenFromBearerString(String token) { + return token.replaceFirst(valueConfig.getBEARER_HEADER(), valueConfig.getBLANK()); + } +} diff --git a/src/main/java/com/soptie/server/auth/jwt/JwtTokenProvider.java b/src/main/java/com/soptie/server/auth/jwt/JwtTokenProvider.java new file mode 100644 index 00000000..a3f1c6ba --- /dev/null +++ b/src/main/java/com/soptie/server/auth/jwt/JwtTokenProvider.java @@ -0,0 +1,78 @@ +package com.soptie.server.auth.jwt; + +import com.soptie.server.common.config.ValueConfig; +import io.jsonwebtoken.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import lombok.val; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Date; + +import static com.soptie.server.auth.jwt.JwtValidationType.*; +import static io.jsonwebtoken.Header.*; +import static io.jsonwebtoken.security.Keys.hmacShaKeyFor; +import static java.util.Base64.getEncoder; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtTokenProvider { + + private final ValueConfig valueConfig; + + public String generateToken(Authentication authentication, long expiration) { + return Jwts.builder() + .setHeaderParam(TYPE, JWT_TYPE) + .setClaims(generateClaims(authentication)) + .setIssuedAt(new Date(System.currentTimeMillis())) + .setExpiration(new Date(System.currentTimeMillis() + expiration)) + .signWith(getSigningKey()) + .compact(); + } + + public JwtValidationType validateToken(String token) { + try { + getBody(token); + return VALID_JWT; + } catch (MalformedJwtException exception) { + log.error(exception.getMessage()); + return INVALID_JWT_TOKEN; + } catch (ExpiredJwtException exception) { + log.error(exception.getMessage()); + return EXPIRED_JWT_TOKEN; + } catch (UnsupportedJwtException exception) { + log.error(exception.getMessage()); + return UNSUPPORTED_JWT_TOKEN; + } catch (IllegalArgumentException exception) { + log.error(exception.getMessage()); + return EMPTY_JWT; + } + } + + private Claims generateClaims(Authentication authentication) { + val claims = Jwts.claims(); + claims.put("memberId", authentication.getPrincipal()); + return claims; + } + + public Long getUserFromJwt(String token) { + val claims = getBody(token); + return Long.parseLong(claims.get("memberId").toString()); + } + + private Claims getBody(final String token) { + return Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody(); + } + + private SecretKey getSigningKey() { + val encodedKey = getEncoder().encodeToString(valueConfig.getSecretKey().getBytes()); + return hmacShaKeyFor(encodedKey.getBytes()); + } +} diff --git a/src/main/java/com/soptie/server/auth/jwt/JwtValidationType.java b/src/main/java/com/soptie/server/auth/jwt/JwtValidationType.java new file mode 100644 index 00000000..3f22222a --- /dev/null +++ b/src/main/java/com/soptie/server/auth/jwt/JwtValidationType.java @@ -0,0 +1,9 @@ +package com.soptie.server.auth.jwt; + +public enum JwtValidationType { + VALID_JWT, + INVALID_JWT_TOKEN, + EXPIRED_JWT_TOKEN, + UNSUPPORTED_JWT_TOKEN, + EMPTY_JWT +} diff --git a/src/main/java/com/soptie/server/auth/jwt/UserAuthentication.java b/src/main/java/com/soptie/server/auth/jwt/UserAuthentication.java new file mode 100644 index 00000000..3e07e766 --- /dev/null +++ b/src/main/java/com/soptie/server/auth/jwt/UserAuthentication.java @@ -0,0 +1,13 @@ +package com.soptie.server.auth.jwt; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +import java.util.Collection; + +public class UserAuthentication extends UsernamePasswordAuthenticationToken { + + public UserAuthentication(Object principal, Object credentials, Collection authorities) { + super(principal, credentials, authorities); + } +} diff --git a/src/main/java/com/soptie/server/auth/message/ErrorCode.java b/src/main/java/com/soptie/server/auth/message/ErrorCode.java new file mode 100644 index 00000000..8cf415fa --- /dev/null +++ b/src/main/java/com/soptie/server/auth/message/ErrorCode.java @@ -0,0 +1,21 @@ +package com.soptie.server.auth.message; + +import static org.springframework.http.HttpStatus.*; + +import org.springframework.http.HttpStatus; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum ErrorCode { + + /* 401 UNAUTHORIZED : 권한 없음 */ + INVALID_TOKEN(UNAUTHORIZED, "유효하지 않은 토큰입니다."), + INVALID_KEY(UNAUTHORIZED, "유효하지 않은 키입니다."), + ; + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/soptie/server/auth/message/SuccessMessage.java b/src/main/java/com/soptie/server/auth/message/SuccessMessage.java new file mode 100644 index 00000000..36d2a2ca --- /dev/null +++ b/src/main/java/com/soptie/server/auth/message/SuccessMessage.java @@ -0,0 +1,17 @@ +package com.soptie.server.auth.message; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public enum SuccessMessage { + + SUCCESS_SIGN_IN("소셜로그인 성공"), + SUCCESS_RECREATE_TOKEN("토큰 재발급 성공"), + SUCCESS_SIGN_OUT("로그아웃 성공"), + SUCCESS_WITHDRAWAL("회원 탈퇴 성공"), + ; + + private final String message; +} diff --git a/src/main/java/com/soptie/server/auth/service/AppleService.java b/src/main/java/com/soptie/server/auth/service/AppleService.java new file mode 100644 index 00000000..c8c6cfcb --- /dev/null +++ b/src/main/java/com/soptie/server/auth/service/AppleService.java @@ -0,0 +1,6 @@ +package com.soptie.server.auth.service; + +public interface AppleService { + + String getAppleData(String socialAccessToken); +} diff --git a/src/main/java/com/soptie/server/auth/service/AppleServiceImpl.java b/src/main/java/com/soptie/server/auth/service/AppleServiceImpl.java new file mode 100644 index 00000000..5a535931 --- /dev/null +++ b/src/main/java/com/soptie/server/auth/service/AppleServiceImpl.java @@ -0,0 +1,148 @@ +package com.soptie.server.auth.service; + +import com.google.gson.*; +import com.soptie.server.auth.exception.AuthException; +import com.soptie.server.common.config.ValueConfig; +import io.jsonwebtoken.Jwts; +import lombok.RequiredArgsConstructor; +import lombok.val; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.math.BigInteger; +import java.net.HttpURLConnection; +import java.net.URL; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.RSAPublicKeySpec; +import java.util.Base64; +import java.util.Objects; + +import static com.soptie.server.auth.message.ErrorCode.*; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AppleServiceImpl implements AppleService { + + private final ValueConfig valueConfig; + + @Override + public String getAppleData(String socialAccessToken) { + val publicKeyList = getApplePublicKeys(); + val publicKey = makePublicKey(socialAccessToken, publicKeyList); + + val userInfo = Jwts.parserBuilder() + .setSigningKey(publicKey) + .build() + .parseClaimsJws(getTokenFromBearerString(socialAccessToken)) + .getBody(); + + val userInfoObject = (JsonObject) JsonParser.parseString(new Gson().toJson(userInfo)); + return userInfoObject.get(valueConfig.getID()).getAsString(); + } + + private JsonArray getApplePublicKeys() { + val connection = sendHttpRequest(); + val result = getHttpResponse(connection); + val keys = (JsonObject) JsonParser.parseString(result.toString()); + return (JsonArray) keys.get(valueConfig.getKEY()); + } + + private HttpURLConnection sendHttpRequest() { + try { + val url = new URL(valueConfig.getAppleUri()); + val connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod(HttpMethod.GET.name()); + return connection; + } catch (IOException exception) { + throw new RuntimeException(exception); + } + } + + private StringBuilder getHttpResponse(HttpURLConnection connection) { + try { + val bufferedReader = new BufferedReader(new InputStreamReader(connection.getInputStream())); + return splitHttpResponse(bufferedReader); + } catch (IOException exception) { + throw new RuntimeException(exception); + } + } + + private StringBuilder splitHttpResponse(BufferedReader bufferedReader) { + try { + val result = new StringBuilder(); + + String line; + while (Objects.nonNull(line = bufferedReader.readLine())) { + result.append(line); + } + bufferedReader.close(); + + return result; + } catch (IOException exception) { + throw new RuntimeException(exception); + } + } + + private PublicKey makePublicKey(String accessToken, JsonArray publicKeyList) { + val decodeArray = accessToken.split(valueConfig.getTOKEN_VALUE_DELIMITER()); + val header = new String(Base64.getDecoder().decode(getTokenFromBearerString(decodeArray[0]))); + + val kid = ((JsonObject) JsonParser.parseString(header)).get(valueConfig.getKID_HEADER_KEY()); + val alg = ((JsonObject) JsonParser.parseString(header)).get(valueConfig.getALG_HEADER_KEY()); + val matchingPublicKey = findMatchingPublicKey(publicKeyList, kid, alg); + + if (Objects.isNull(matchingPublicKey)) { + throw new AuthException(INVALID_KEY); + } + + return getPublicKey(matchingPublicKey); + } + + private String getTokenFromBearerString(String token) { + return token.replaceFirst(valueConfig.getBEARER_HEADER(), valueConfig.getBLANK()); + } + + private JsonObject findMatchingPublicKey(JsonArray publicKeyList, JsonElement kid, JsonElement alg) { + for (JsonElement publicKey : publicKeyList) { + val publicKeyObject = publicKey.getAsJsonObject(); + val publicKid = publicKeyObject.get(valueConfig.getKID_HEADER_KEY()); + val publicAlg = publicKeyObject.get(valueConfig.getALG_HEADER_KEY()); + + if (Objects.equals(kid, publicKid) && Objects.equals(alg, publicAlg)) { + return publicKeyObject; + } + } + + return null; + } + + private PublicKey getPublicKey(JsonObject object) { + try { + val modulus = object.get(valueConfig.getMODULUS()).toString(); + val exponent = object.get(valueConfig.getEXPONENT()).toString(); + + val quotes = valueConfig.getQUOTES(); + val modulusBytes = Base64.getUrlDecoder().decode(modulus.substring(quotes, modulus.length() - quotes)); + val exponentBytes = Base64.getUrlDecoder().decode(exponent.substring(quotes, exponent.length() - quotes)); + + val positiveNumber = valueConfig.getPOSITIVE_NUMBER(); + val modulusValue = new BigInteger(positiveNumber, modulusBytes); + val exponentValue = new BigInteger(positiveNumber, exponentBytes); + + val publicKeySpec = new RSAPublicKeySpec(modulusValue, exponentValue); + val keyFactory = KeyFactory.getInstance(valueConfig.getRSA()); + + return keyFactory.generatePublic(publicKeySpec); + } catch (InvalidKeySpecException | NoSuchAlgorithmException exception) { + throw new AuthException(INVALID_KEY); + } + } +} diff --git a/src/main/java/com/soptie/server/auth/service/AuthService.java b/src/main/java/com/soptie/server/auth/service/AuthService.java new file mode 100644 index 00000000..bb31fe8c --- /dev/null +++ b/src/main/java/com/soptie/server/auth/service/AuthService.java @@ -0,0 +1,13 @@ +package com.soptie.server.auth.service; + +import com.soptie.server.auth.dto.SignInRequest; +import com.soptie.server.auth.dto.SignInResponse; +import com.soptie.server.auth.dto.TokenResponse; + +public interface AuthService { + + SignInResponse signIn(String socialAccessToken, SignInRequest request); + void signOut(long memberId); + void withdraw(long memberId); + TokenResponse reissueToken(String refreshToken); +} diff --git a/src/main/java/com/soptie/server/auth/service/AuthServiceImpl.java b/src/main/java/com/soptie/server/auth/service/AuthServiceImpl.java new file mode 100644 index 00000000..d5a72a8d --- /dev/null +++ b/src/main/java/com/soptie/server/auth/service/AuthServiceImpl.java @@ -0,0 +1,160 @@ +package com.soptie.server.auth.service; + +import com.soptie.server.auth.dto.SignInRequest; +import com.soptie.server.auth.dto.SignInResponse; +import com.soptie.server.auth.dto.TokenResponse; +import com.soptie.server.auth.jwt.JwtTokenProvider; +import com.soptie.server.auth.jwt.UserAuthentication; +import com.soptie.server.auth.vo.Token; +import com.soptie.server.common.config.ValueConfig; +import com.soptie.server.member.entity.Member; +import com.soptie.server.member.entity.SocialType; +import com.soptie.server.member.exception.MemberException; +import com.soptie.server.member.repository.MemberRepository; +import com.soptie.server.member.service.MemberService; +import com.soptie.server.memberDoll.entity.MemberDoll; +import com.soptie.server.memberDoll.service.MemberDollService; +import com.soptie.server.memberRoutine.entity.daily.MemberDailyRoutine; +import com.soptie.server.memberRoutine.entity.happiness.MemberHappinessRoutine; +import com.soptie.server.memberRoutine.service.CompletedMemberDailyRoutineService; +import com.soptie.server.memberRoutine.service.MemberDailyRoutineService; +import com.soptie.server.memberRoutine.service.MemberHappinessRoutineService; +import lombok.RequiredArgsConstructor; +import lombok.val; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Objects; + +import static com.soptie.server.member.message.ErrorCode.INVALID_MEMBER; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AuthServiceImpl implements AuthService { + + private final JwtTokenProvider jwtTokenProvider; + private final MemberRepository memberRepository; + private final KakaoService kakaoService; + private final AppleService appleService; + private final MemberService memberService; + private final MemberDailyRoutineService memberDailyRoutineService; + private final MemberHappinessRoutineService memberHappinessRoutineService; + private final MemberDollService memberDollService; + private final CompletedMemberDailyRoutineService completedMemberDailyRoutineService; + private final ValueConfig valueConfig; + + @Override + @Transactional + public SignInResponse signIn(String socialAccessToken, SignInRequest request) { + val member = getMember(socialAccessToken, request); + val token = getToken(member); + val isMemberDollExist = member.isMemberDollExist(); + return SignInResponse.of(token, isMemberDollExist); + } + + @Override + public TokenResponse reissueToken(String refreshToken) { + val member = memberRepository.findByRefreshToken(getTokenFromBearerString(refreshToken)) + .orElseThrow(() -> new MemberException(INVALID_MEMBER)); + val token = generateAccessToken(new UserAuthentication(member.getId(), null, null)); + return TokenResponse.of(token); + } + + private String getTokenFromBearerString(String token) { + return token.replaceFirst(valueConfig.getBEARER_HEADER(), valueConfig.getBLANK()); + } + + @Override + @Transactional + public void signOut(long memberId) { + val member = findMember(memberId); + member.resetRefreshToken(); + } + + @Override + @Transactional + public void withdraw(long memberId) { + val member = findMember(memberId); + deleteMemberDoll(member.getMemberDoll()); + deleteMemberDailyRoutines(member.getDailyRoutines()); + deleteMemberHappinessRoutine(member.getHappinessRoutine()); + deleteCompletedMemberDailyRoutines(member); + deleteMember(member); + } + + private Member getMember(String socialAccessToken, SignInRequest request) { + val socialType = request.socialType(); + val socialId = getSocialId(socialAccessToken, socialType); + return signUp(socialType, socialId); + } + + private String getSocialId(String socialAccessToken, SocialType socialType) { + return switch (socialType) { + case APPLE -> appleService.getAppleData(socialAccessToken); + case KAKAO -> kakaoService.getKakaoData(socialAccessToken); + }; + } + + private Member signUp(SocialType socialType, String socialId) { + return memberRepository.findBySocialTypeAndSocialId(socialType, socialId) + .orElseGet(() -> saveMember(socialType, socialId)); + } + + private Member saveMember(SocialType socialType, String socialId) { + val member = Member.builder() + .socialType(socialType) + .socialId(socialId) + .build(); + return memberRepository.save(member); + } + + private Token getToken(Member member) { + val token = generateToken(new UserAuthentication(member.getId(), null, null)); + member.updateRefreshToken(token.getRefreshToken()); + return token; + } + + private Token generateToken(Authentication authentication) { + return Token.builder() + .accessToken(jwtTokenProvider.generateToken(authentication, valueConfig.getAccessTokenExpired())) + .refreshToken(jwtTokenProvider.generateToken(authentication, valueConfig.getRefreshTokenExpired())) + .build(); + } + + private Member findMember(long id) { + return memberRepository.findById(id) + .orElseThrow(() -> new MemberException(INVALID_MEMBER)); + } + + private String generateAccessToken(Authentication authentication) { + return jwtTokenProvider.generateToken(authentication, valueConfig.getAccessTokenExpired()); + } + + private void deleteMemberDoll(MemberDoll memberDoll) { + if (Objects.nonNull(memberDoll)) { + memberDollService.deleteMemberDoll(memberDoll); + } + } + + private void deleteMemberDailyRoutines(List memberDailyRoutines) { + memberDailyRoutines + .forEach(memberDailyRoutineService::deleteMemberDailyRoutine); + } + + private void deleteMemberHappinessRoutine(MemberHappinessRoutine memberHappinessRoutine) { + if (Objects.nonNull(memberHappinessRoutine)) { + memberHappinessRoutineService.deleteMemberHappinessRoutine(memberHappinessRoutine); + } + } + + private void deleteCompletedMemberDailyRoutines(Member member) { + completedMemberDailyRoutineService.deleteCompletedMemberDailyRoutines(member); + } + + private void deleteMember(Member member) { + memberService.deleteMember(member); + } +} diff --git a/src/main/java/com/soptie/server/auth/service/KakaoService.java b/src/main/java/com/soptie/server/auth/service/KakaoService.java new file mode 100644 index 00000000..ee39320c --- /dev/null +++ b/src/main/java/com/soptie/server/auth/service/KakaoService.java @@ -0,0 +1,6 @@ +package com.soptie.server.auth.service; + +public interface KakaoService { + + String getKakaoData(String socialAccessToken); +} diff --git a/src/main/java/com/soptie/server/auth/service/KakaoServiceImpl.java b/src/main/java/com/soptie/server/auth/service/KakaoServiceImpl.java new file mode 100644 index 00000000..5f276ba0 --- /dev/null +++ b/src/main/java/com/soptie/server/auth/service/KakaoServiceImpl.java @@ -0,0 +1,38 @@ +package com.soptie.server.auth.service; + +import static com.soptie.server.auth.message.ErrorCode.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.gson.JsonArray; +import com.soptie.server.auth.exception.AuthException; +import com.soptie.server.common.config.ValueConfig; +import lombok.RequiredArgsConstructor; +import lombok.val; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class KakaoServiceImpl implements KakaoService { + + private final ValueConfig valueConfig; + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + + @Override + public String getKakaoData(String socialAccessToken) { + try { + val headers = new HttpHeaders(); + headers.add("Authorization", socialAccessToken); + val httpEntity = new HttpEntity(headers); + val responseData = restTemplate.postForEntity(valueConfig.getKakaoUri(), httpEntity, Object.class); + return objectMapper.convertValue(responseData.getBody(), Map.class).get("id").toString(); + } catch (Exception exception) { + throw new AuthException(INVALID_TOKEN); + } + } +} diff --git a/src/main/java/com/soptie/server/auth/vo/Token.java b/src/main/java/com/soptie/server/auth/vo/Token.java new file mode 100644 index 00000000..76f17d0a --- /dev/null +++ b/src/main/java/com/soptie/server/auth/vo/Token.java @@ -0,0 +1,32 @@ +package com.soptie.server.auth.vo; + +import lombok.Builder; +import lombok.Getter; + +import java.util.Objects; + +@Getter +public class Token { + + private final String accessToken; + private final String refreshToken; + + @Builder + public Token(String accessToken, String refreshToken) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Token token = (Token) o; + return Objects.equals(accessToken, token.accessToken) && Objects.equals(refreshToken, token.refreshToken); + } + + @Override + public int hashCode() { + return Objects.hash(accessToken, refreshToken); + } +} diff --git a/src/main/java/com/soptie/server/common/config/JpaAuditingConfig.java b/src/main/java/com/soptie/server/common/config/JpaAuditingConfig.java new file mode 100644 index 00000000..9a4b314c --- /dev/null +++ b/src/main/java/com/soptie/server/common/config/JpaAuditingConfig.java @@ -0,0 +1,9 @@ +package com.soptie.server.common.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaAuditingConfig { +} diff --git a/src/main/java/com/soptie/server/common/config/JpaQueryFactoryConfig.java b/src/main/java/com/soptie/server/common/config/JpaQueryFactoryConfig.java new file mode 100644 index 00000000..1d53ad24 --- /dev/null +++ b/src/main/java/com/soptie/server/common/config/JpaQueryFactoryConfig.java @@ -0,0 +1,17 @@ +package com.soptie.server.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.querydsl.jpa.impl.JPAQueryFactory; + +import jakarta.persistence.EntityManager; + +@Configuration +public class JpaQueryFactoryConfig { + + @Bean + JPAQueryFactory jpaQueryFactory(EntityManager em) { + return new JPAQueryFactory(em); + } +} diff --git a/src/main/java/com/soptie/server/common/config/RestTemplateConfig.java b/src/main/java/com/soptie/server/common/config/RestTemplateConfig.java new file mode 100644 index 00000000..b0102b7f --- /dev/null +++ b/src/main/java/com/soptie/server/common/config/RestTemplateConfig.java @@ -0,0 +1,14 @@ +package com.soptie.server.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} diff --git a/src/main/java/com/soptie/server/common/config/SecurityConfig.java b/src/main/java/com/soptie/server/common/config/SecurityConfig.java new file mode 100644 index 00000000..aa648b97 --- /dev/null +++ b/src/main/java/com/soptie/server/common/config/SecurityConfig.java @@ -0,0 +1,67 @@ +package com.soptie.server.common.config; + +import com.soptie.server.auth.jwt.CustomJwtAuthenticationEntryPoint; +import com.soptie.server.auth.jwt.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +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.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final CustomJwtAuthenticationEntryPoint customJwtAuthenticationEntryPoint; + + @Bean + @Profile("dev") + public SecurityFilterChain filterChainDev(HttpSecurity http) throws Exception { + permitSwaggerUri(http); + setHttp(http); + return http.build(); + } + + private void permitSwaggerUri(HttpSecurity http) throws Exception { + http.authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests + .requestMatchers(new AntPathRequestMatcher("/v3/api-docs/**")).permitAll() + .requestMatchers(new AntPathRequestMatcher("/swagger-ui/**")).permitAll() + .requestMatchers(new AntPathRequestMatcher("/docs/**")).permitAll()); + } + + @Bean + @Profile("prod") + public SecurityFilterChain filterChainProd(HttpSecurity http) throws Exception { + setHttp(http); + return http.build(); + } + + private void setHttp(HttpSecurity http) throws Exception { + http.csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .sessionManagement(sessionManagement -> + sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .exceptionHandling(exceptionHandling -> + exceptionHandling.authenticationEntryPoint(customJwtAuthenticationEntryPoint)) + .authorizeHttpRequests(authorizeHttpRequests -> + authorizeHttpRequests + .requestMatchers(new AntPathRequestMatcher("/api/v1/auth", "POST")).permitAll() + .requestMatchers(new AntPathRequestMatcher("/api/v1/test")).permitAll() + .requestMatchers(new AntPathRequestMatcher("/api/v1/routines/daily/themes")).permitAll() + .requestMatchers(new AntPathRequestMatcher("/api/v1/routines/daily")).permitAll() + .requestMatchers(new AntPathRequestMatcher("/api/v1/dolls/image/{type}")).permitAll() + .requestMatchers(new AntPathRequestMatcher("/api/v1/versions/client/app")).permitAll() + .requestMatchers(new AntPathRequestMatcher("/error")).permitAll() + .anyRequest().authenticated()) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + } +} diff --git a/src/main/java/com/soptie/server/common/config/TimezoneConfig.java b/src/main/java/com/soptie/server/common/config/TimezoneConfig.java new file mode 100644 index 00000000..74bee708 --- /dev/null +++ b/src/main/java/com/soptie/server/common/config/TimezoneConfig.java @@ -0,0 +1,16 @@ +package com.soptie.server.common.config; + +import java.util.TimeZone; + +import org.springframework.context.annotation.Configuration; + +import jakarta.annotation.PostConstruct; + +@Configuration +public class TimezoneConfig { + + @PostConstruct + public void init() { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); + } +} diff --git a/src/main/java/com/soptie/server/common/config/ValueConfig.java b/src/main/java/com/soptie/server/common/config/ValueConfig.java new file mode 100644 index 00000000..7818443a --- /dev/null +++ b/src/main/java/com/soptie/server/common/config/ValueConfig.java @@ -0,0 +1,69 @@ +package com.soptie.server.common.config; + +import jakarta.annotation.PostConstruct; +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +@Configuration +@Getter +public class ValueConfig { + + @Value("${jwt.secret}") + private String secretKey; + + @Value("${spring.security.oauth2.client.registration.kakao.client-id") + private String clientId; + + @Value("${spring.security.oauth2.client.registration.kakao.redirect-uri") + private String redirectUri; + + @Value("${spring.security.oauth2.client.registration.kakao.client-secret") + private String clientSecret; + + @Value("${spring.security.oauth2.client.provider.kakao.token-uri") + private String tokenUri; + + @Value("${jwt.KAKAO_URL}") + private String kakaoUri; + + @Value("${jwt.APPLE_URL}") + private String appleUri; + + @Value("${jwt.ACCESS_TOKEN_EXPIRED}") + private Long accessTokenExpired; + + @Value("${jwt.REFRESH_TOKEN_EXPIRED}") + private Long refreshTokenExpired; + + private final String IOS_FORCE_UPDATE_VERSION = "0.0.9"; + private final String IOS_APP_VERSION = "1.0.0"; + private final String ANDROID_FORCE_UPDATE_VERSION = "0.0.9"; + private final String ANDROID_APP_VERSION = "1.0.0"; + private final String NOTIFICATION_TITLE = "새로운 버전이 업데이트 되었어요!"; + private final String NOTIFICATION_CONTENT = "안정적인 서비스 사용을 위해\n최신버전으로 업데이트 해주세요."; + private final String TOKEN_VALUE_DELIMITER = "\\."; + private final String BEARER_HEADER = "Bearer "; + private final String BLANK = ""; + private final String MODULUS = "n"; + private final String EXPONENT = "e"; + private final String KID_HEADER_KEY = "kid"; + private final String ALG_HEADER_KEY = "alg"; + private final String RSA = "RSA"; + private final String KEY = "keys"; + private final String ID = "sub"; + private final int QUOTES = 1; + private final int POSITIVE_NUMBER = 1; + + public static final int MIN_COTTON_COUNT = 0; + public static final int DAILY_ROUTINE_MAX_COUNT = 3; + public static final String MEMBER_DOLL_CONDITION = "^[가-힣a-zA-Zㄱ-ㅎㅏ-ㅣ]{1,10}$"; + + @PostConstruct + protected void init() { + secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/src/main/java/com/soptie/server/common/dto/Response.java b/src/main/java/com/soptie/server/common/dto/Response.java new file mode 100644 index 00000000..05374758 --- /dev/null +++ b/src/main/java/com/soptie/server/common/dto/Response.java @@ -0,0 +1,22 @@ +package com.soptie.server.common.dto; + +import lombok.NonNull; + +public record Response( + boolean success, + @NonNull String message, + Object data +) { + + public static Response success(String message, Object data) { + return new Response(true, message, data); + } + + public static Response success(String message) { + return new Response(true, message, null); + } + + public static Response fail(String message) { + return new Response(false, message, null); + } +} diff --git a/src/main/java/com/soptie/server/common/entity/BaseTime.java b/src/main/java/com/soptie/server/common/entity/BaseTime.java new file mode 100644 index 00000000..db8aa132 --- /dev/null +++ b/src/main/java/com/soptie/server/common/entity/BaseTime.java @@ -0,0 +1,24 @@ +package com.soptie.server.common.entity; + +import java.time.LocalDateTime; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; + +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +@Getter +public class BaseTime { + + @CreatedDate + protected LocalDateTime createdAt; + + @LastModifiedDate + protected LocalDateTime updatedAt; + +} diff --git a/src/main/java/com/soptie/server/common/handler/ErrorHandler.java b/src/main/java/com/soptie/server/common/handler/ErrorHandler.java new file mode 100644 index 00000000..9b403b5f --- /dev/null +++ b/src/main/java/com/soptie/server/common/handler/ErrorHandler.java @@ -0,0 +1,62 @@ +package com.soptie.server.common.handler; + +import static com.soptie.server.common.dto.Response.*; + +import com.soptie.server.memberDoll.exception.MemberDollException; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import com.soptie.server.auth.exception.AuthException; +import com.soptie.server.common.dto.Response; +import com.soptie.server.doll.exception.DollException; +import com.soptie.server.member.exception.MemberException; +import com.soptie.server.routine.exception.RoutineException; + +import lombok.extern.slf4j.Slf4j; +import lombok.*; + +@Slf4j +@RestControllerAdvice +public class ErrorHandler { + + @ExceptionHandler(AuthException.class) + public ResponseEntity authException(AuthException exception) { + log.error(exception.getMessage()); + + val errorCode = exception.getErrorCode(); + return ResponseEntity.status(errorCode.getHttpStatus()).body(fail(errorCode.getMessage())); + } + + @ExceptionHandler(DollException.class) + public ResponseEntity dollException(DollException exception) { + log.error(exception.getMessage()); + + val errorCode = exception.getErrorCode(); + return ResponseEntity.status(errorCode.getHttpStatus()).body(fail(errorCode.getMessage())); + } + + @ExceptionHandler(MemberException.class) + public ResponseEntity memberException(MemberException exception) { + log.error(exception.getMessage()); + + val errorCode = exception.getErrorCode(); + return ResponseEntity.status(errorCode.getHttpStatus()).body(fail(errorCode.getMessage())); + } + + @ExceptionHandler(MemberDollException.class) + public ResponseEntity memberDollException(MemberDollException exception) { + log.error(exception.getMessage()); + + val errorCode = exception.getErrorCode(); + return ResponseEntity.status(errorCode.getHttpStatus()).body(fail(errorCode.getMessage())); + } + + @ExceptionHandler(RoutineException.class) + public ResponseEntity routineException(RoutineException exception) { + log.error(exception.getMessage()); + + val errorCode = exception.getErrorCode(); + return ResponseEntity.status(errorCode.getHttpStatus()).body(fail(errorCode.getMessage())); + } +} diff --git a/src/main/java/com/soptie/server/conversation/entity/Conversation.java b/src/main/java/com/soptie/server/conversation/entity/Conversation.java new file mode 100644 index 00000000..afdbb16c --- /dev/null +++ b/src/main/java/com/soptie/server/conversation/entity/Conversation.java @@ -0,0 +1,22 @@ +package com.soptie.server.conversation.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor +@Getter +public class Conversation { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "conversation_id") + private Long id; + + private String content; +} diff --git a/src/main/java/com/soptie/server/conversation/repository/ConversationRepository.java b/src/main/java/com/soptie/server/conversation/repository/ConversationRepository.java new file mode 100644 index 00000000..3151dbf8 --- /dev/null +++ b/src/main/java/com/soptie/server/conversation/repository/ConversationRepository.java @@ -0,0 +1,7 @@ +package com.soptie.server.conversation.repository; + +import com.soptie.server.conversation.entity.Conversation; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ConversationRepository extends JpaRepository { +} diff --git a/src/main/java/com/soptie/server/doll/controller/DollController.java b/src/main/java/com/soptie/server/doll/controller/DollController.java new file mode 100644 index 00000000..7bfc4a0c --- /dev/null +++ b/src/main/java/com/soptie/server/doll/controller/DollController.java @@ -0,0 +1,30 @@ +package com.soptie.server.doll.controller; + +import static com.soptie.server.common.dto.Response.*; +import static com.soptie.server.doll.message.SuccessMessage.*; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.soptie.server.common.dto.Response; +import com.soptie.server.doll.entity.DollType; +import com.soptie.server.doll.service.DollService; + +import lombok.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/dolls") +public class DollController { + + private final DollService dollService; + + @GetMapping("/image/{type}") + public ResponseEntity getDollImages(@PathVariable DollType type) { + val response = dollService.getDollImage(type); + return ResponseEntity.ok(success(SUCCESS_GET_IMAGE.getMessage(), response)); + } +} diff --git a/src/main/java/com/soptie/server/doll/dto/DollImageResponse.java b/src/main/java/com/soptie/server/doll/dto/DollImageResponse.java new file mode 100644 index 00000000..4cd69c9c --- /dev/null +++ b/src/main/java/com/soptie/server/doll/dto/DollImageResponse.java @@ -0,0 +1,14 @@ +package com.soptie.server.doll.dto; + +import com.soptie.server.doll.entity.Doll; + +import lombok.NonNull; + +public record DollImageResponse( + @NonNull String faceImageUrl +) { + + public static DollImageResponse of(Doll doll) { + return new DollImageResponse(doll.getImageInfo().getFaceImageUrl()); + } +} diff --git a/src/main/java/com/soptie/server/doll/entity/Doll.java b/src/main/java/com/soptie/server/doll/entity/Doll.java new file mode 100644 index 00000000..b16312a8 --- /dev/null +++ b/src/main/java/com/soptie/server/doll/entity/Doll.java @@ -0,0 +1,34 @@ +package com.soptie.server.doll.entity; + +import jakarta.persistence.Column; +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 lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor +@Getter +public class Doll { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "doll_id") + private Long id; + + @Enumerated(value = EnumType.STRING) + private DollType dollType; + + @Embedded + private DollImage imageInfo; + + public Doll(DollType dollType, DollImage imageInfo) { + this.dollType = dollType; + this.imageInfo = imageInfo; + } +} diff --git a/src/main/java/com/soptie/server/doll/entity/DollImage.java b/src/main/java/com/soptie/server/doll/entity/DollImage.java new file mode 100644 index 00000000..dd3dac73 --- /dev/null +++ b/src/main/java/com/soptie/server/doll/entity/DollImage.java @@ -0,0 +1,17 @@ +package com.soptie.server.doll.entity; + +import jakarta.persistence.Embeddable; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Embeddable +@NoArgsConstructor +@Getter +public class DollImage { + + private String faceImageUrl; + + private String attentionImageUrl; + + private String frameImageUrl; +} diff --git a/src/main/java/com/soptie/server/doll/entity/DollType.java b/src/main/java/com/soptie/server/doll/entity/DollType.java new file mode 100644 index 00000000..84b3e960 --- /dev/null +++ b/src/main/java/com/soptie/server/doll/entity/DollType.java @@ -0,0 +1,5 @@ +package com.soptie.server.doll.entity; + +public enum DollType { + BROWN, GRAY, RED, WHITE +} diff --git a/src/main/java/com/soptie/server/doll/exception/DollException.java b/src/main/java/com/soptie/server/doll/exception/DollException.java new file mode 100644 index 00000000..d0d8abdb --- /dev/null +++ b/src/main/java/com/soptie/server/doll/exception/DollException.java @@ -0,0 +1,16 @@ +package com.soptie.server.doll.exception; + +import com.soptie.server.doll.message.ErrorCode; + +import lombok.Getter; + +@Getter +public class DollException extends RuntimeException { + + private final ErrorCode errorCode; + + public DollException(ErrorCode errorCode) { + super("[DollException] : " + errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/soptie/server/doll/message/ErrorCode.java b/src/main/java/com/soptie/server/doll/message/ErrorCode.java new file mode 100644 index 00000000..b78770cf --- /dev/null +++ b/src/main/java/com/soptie/server/doll/message/ErrorCode.java @@ -0,0 +1,24 @@ +package com.soptie.server.doll.message; + +import static org.springframework.http.HttpStatus.*; + +import org.springframework.http.HttpStatus; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum ErrorCode { + + /* 400 BAD_REQUEST : 잘못된 요청 */ + INVALID_TYPE(BAD_REQUEST, "유효하지 않은 인형 타입입니다."), + INVALID_NAME(BAD_REQUEST, "조건에 맞지 않는 이름입니다."), + + /* 404 NOT_FOUND : 자원을 찾을 수 없음 */ + NOT_EXIST_MEMBER_DOLL(NOT_FOUND, "애착인형이 없는 회원입니다."), + ; + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/soptie/server/doll/message/SuccessMessage.java b/src/main/java/com/soptie/server/doll/message/SuccessMessage.java new file mode 100644 index 00000000..fc2b37a7 --- /dev/null +++ b/src/main/java/com/soptie/server/doll/message/SuccessMessage.java @@ -0,0 +1,14 @@ +package com.soptie.server.doll.message; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public enum SuccessMessage { + + SUCCESS_GET_IMAGE("인형 이미지 조회 성공"), + ; + + private final String message; +} diff --git a/src/main/java/com/soptie/server/doll/repository/DollRepository.java b/src/main/java/com/soptie/server/doll/repository/DollRepository.java new file mode 100644 index 00000000..c35ce03d --- /dev/null +++ b/src/main/java/com/soptie/server/doll/repository/DollRepository.java @@ -0,0 +1,12 @@ +package com.soptie.server.doll.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.soptie.server.doll.entity.Doll; +import com.soptie.server.doll.entity.DollType; + +public interface DollRepository extends JpaRepository { + Optional findByDollType(DollType type); +} diff --git a/src/main/java/com/soptie/server/doll/service/DollService.java b/src/main/java/com/soptie/server/doll/service/DollService.java new file mode 100644 index 00000000..c2fa9e8b --- /dev/null +++ b/src/main/java/com/soptie/server/doll/service/DollService.java @@ -0,0 +1,8 @@ +package com.soptie.server.doll.service; + +import com.soptie.server.doll.dto.DollImageResponse; +import com.soptie.server.doll.entity.DollType; + +public interface DollService { + DollImageResponse getDollImage(DollType type); +} diff --git a/src/main/java/com/soptie/server/doll/service/DollServiceImpl.java b/src/main/java/com/soptie/server/doll/service/DollServiceImpl.java new file mode 100644 index 00000000..bb49b553 --- /dev/null +++ b/src/main/java/com/soptie/server/doll/service/DollServiceImpl.java @@ -0,0 +1,33 @@ +package com.soptie.server.doll.service; + +import static com.soptie.server.doll.message.ErrorCode.*; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.soptie.server.doll.dto.DollImageResponse; +import com.soptie.server.doll.entity.Doll; +import com.soptie.server.doll.entity.DollType; +import com.soptie.server.doll.exception.DollException; +import com.soptie.server.doll.repository.DollRepository; + +import lombok.*; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class DollServiceImpl implements DollService { + + private final DollRepository dollRepository; + + @Override + public DollImageResponse getDollImage(DollType type) { + val doll = findDoll(type); + return DollImageResponse.of(doll); + } + + private Doll findDoll(DollType type) { + return dollRepository.findByDollType(type) + .orElseThrow(() -> new DollException(INVALID_TYPE)); + } +} diff --git a/src/main/java/com/soptie/server/member/controller/MemberController.java b/src/main/java/com/soptie/server/member/controller/MemberController.java new file mode 100644 index 00000000..7e951981 --- /dev/null +++ b/src/main/java/com/soptie/server/member/controller/MemberController.java @@ -0,0 +1,57 @@ +package com.soptie.server.member.controller; + +import com.soptie.server.common.dto.Response; +import com.soptie.server.member.dto.MemberProfileRequest; +import com.soptie.server.member.entity.CottonType; +import com.soptie.server.member.service.MemberService; +import lombok.RequiredArgsConstructor; +import lombok.val; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +import java.net.URI; +import java.security.Principal; + +import static com.soptie.server.common.dto.Response.success; +import static com.soptie.server.member.message.SuccessMessage.SUCCESS_CREATE_PROFILE; +import static com.soptie.server.member.message.SuccessMessage.SUCCESS_GIVE_COTTON; +import static com.soptie.server.member.message.SuccessMessage.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("api/v1/members") +public class MemberController { + + private final MemberService memberService; + + @PostMapping + public ResponseEntity createMemberProfile(Principal principal, @RequestBody MemberProfileRequest request) { + val memberId = Long.parseLong(principal.getName()); + memberService.createMemberProfile(memberId, request); + return ResponseEntity.created(getURI()) + .body(success(SUCCESS_CREATE_PROFILE.getMessage())); + } + + private URI getURI() { + return ServletUriComponentsBuilder + .fromCurrentRequest() + .path("/") + .buildAndExpand() + .toUri(); + } + + @PatchMapping("/cotton/{cottonType}") + public ResponseEntity giveCotton(Principal principal, @PathVariable CottonType cottonType) { + val memberId = Long.parseLong(principal.getName()); + val response = memberService.giveCotton(memberId, cottonType); + return ResponseEntity.ok(success(SUCCESS_GIVE_COTTON.getMessage(), response)); + } + + @GetMapping + public ResponseEntity getMemberHomeInfo(Principal principal) { + val memberId = Long.parseLong(principal.getName()); + val response = memberService.getMemberHomeInfo(memberId); + return ResponseEntity.ok(success(SUCCESS_HOME_INFO.getMessage(), response)); + } +} diff --git a/src/main/java/com/soptie/server/member/dto/CottonCountResponse.java b/src/main/java/com/soptie/server/member/dto/CottonCountResponse.java new file mode 100644 index 00000000..78983a56 --- /dev/null +++ b/src/main/java/com/soptie/server/member/dto/CottonCountResponse.java @@ -0,0 +1,15 @@ +package com.soptie.server.member.dto; + +import lombok.Builder; + +@Builder +public record CottonCountResponse( + int cottonCount +) { + + public static CottonCountResponse of(int cottonCount) { + return CottonCountResponse.builder() + .cottonCount(cottonCount) + .build(); + } +} diff --git a/src/main/java/com/soptie/server/member/dto/MemberHomeInfoResponse.java b/src/main/java/com/soptie/server/member/dto/MemberHomeInfoResponse.java new file mode 100644 index 00000000..b0c58f5c --- /dev/null +++ b/src/main/java/com/soptie/server/member/dto/MemberHomeInfoResponse.java @@ -0,0 +1,30 @@ +package com.soptie.server.member.dto; + +import com.soptie.server.doll.entity.DollType; +import com.soptie.server.member.entity.Member; +import lombok.Builder; +import lombok.NonNull; + +import java.util.List; + +@Builder +public record MemberHomeInfoResponse( + @NonNull String name, + @NonNull DollType dollType, + @NonNull String frameImageUrl, + int dailyCottonCount, + int happinessCottonCount, + @NonNull List conversations +) { + + public static MemberHomeInfoResponse of(Member member, List conversations) { + return MemberHomeInfoResponse.builder() + .name(member.getMemberDoll().getName()) + .dollType(member.getMemberDoll().getDoll().getDollType()) + .frameImageUrl(member.getMemberDoll().getDoll().getImageInfo().getFrameImageUrl()) + .dailyCottonCount(member.getCottonInfo().getDailyCottonCount()) + .happinessCottonCount(member.getCottonInfo().getHappinessCottonCount()) + .conversations(conversations) + .build(); + } +} diff --git a/src/main/java/com/soptie/server/member/dto/MemberProfileRequest.java b/src/main/java/com/soptie/server/member/dto/MemberProfileRequest.java new file mode 100644 index 00000000..6393042e --- /dev/null +++ b/src/main/java/com/soptie/server/member/dto/MemberProfileRequest.java @@ -0,0 +1,14 @@ +package com.soptie.server.member.dto; + +import com.soptie.server.doll.entity.DollType; + +import java.util.List; + +import lombok.NonNull; + +public record MemberProfileRequest( + @NonNull DollType dollType, + @NonNull String name, + @NonNull List routines +) { +} diff --git a/src/main/java/com/soptie/server/member/entity/Cotton.java b/src/main/java/com/soptie/server/member/entity/Cotton.java new file mode 100644 index 00000000..1431aa1a --- /dev/null +++ b/src/main/java/com/soptie/server/member/entity/Cotton.java @@ -0,0 +1,57 @@ +package com.soptie.server.member.entity; + +import static com.soptie.server.common.config.ValueConfig.*; +import static com.soptie.server.member.message.ErrorCode.*; + +import com.soptie.server.member.exception.MemberException; +import com.soptie.server.memberDoll.entity.MemberDoll; + +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Embeddable +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PACKAGE) +@Getter +public class Cotton { + + private int dailyCottonCount; + private int happinessCottonCount; + + protected void addDailyCotton() { + this.dailyCottonCount++; + } + + protected void addHappinessCotton(){ + this.happinessCottonCount++; + } + + protected int subtractAndGetCotton(CottonType type, MemberDoll memberDoll) { + return switch (type) { + case DAILY -> subtractAndGetDailyCotton(); + case HAPPINESS -> subtractAndGetHappinessCotton(memberDoll); + }; + } + + private int subtractAndGetDailyCotton() { + checkCount(this.dailyCottonCount); + this.dailyCottonCount--; + return this.dailyCottonCount; + } + + private int subtractAndGetHappinessCotton(MemberDoll memberDoll) { + checkCount(this.happinessCottonCount); + this.happinessCottonCount--; + memberDoll.addHappinessCottonCount(); + return this.happinessCottonCount; + } + + private void checkCount(int count) { + if (count <= MIN_COTTON_COUNT) { + throw new MemberException(NOT_ENOUGH_COTTON); + } + } +} diff --git a/src/main/java/com/soptie/server/member/entity/CottonType.java b/src/main/java/com/soptie/server/member/entity/CottonType.java new file mode 100644 index 00000000..a197929c --- /dev/null +++ b/src/main/java/com/soptie/server/member/entity/CottonType.java @@ -0,0 +1,6 @@ +package com.soptie.server.member.entity; + +public enum CottonType { + DAILY, + HAPPINESS +} diff --git a/src/main/java/com/soptie/server/member/entity/Member.java b/src/main/java/com/soptie/server/member/entity/Member.java new file mode 100644 index 00000000..2a48f684 --- /dev/null +++ b/src/main/java/com/soptie/server/member/entity/Member.java @@ -0,0 +1,144 @@ +package com.soptie.server.member.entity; + +import static com.soptie.server.common.config.ValueConfig.*; +import static com.soptie.server.doll.message.ErrorCode.*; +import static com.soptie.server.member.message.ErrorCode.*; +import static com.soptie.server.routine.message.ErrorCode.*; + +import com.soptie.server.common.entity.BaseTime; +import com.soptie.server.member.exception.MemberException; +import com.soptie.server.memberDoll.entity.MemberDoll; +import com.soptie.server.memberDoll.exception.MemberDollException; +import com.soptie.server.memberRoutine.entity.daily.MemberDailyRoutine; +import com.soptie.server.memberRoutine.entity.happiness.MemberHappinessRoutine; +import com.soptie.server.routine.entity.daily.DailyRoutine; +import com.soptie.server.routine.exception.RoutineException; + +import jakarta.persistence.*; +import lombok.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +@Entity +@NoArgsConstructor +@Getter +public class Member extends BaseTime { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_id") + private Long id; + + @Enumerated(value = EnumType.STRING) + private SocialType socialType; + private String socialId; + + private String refreshToken; + + @Embedded + private Cotton cottonInfo; + + @OneToOne + @JoinColumn(name = "doll_id") + private MemberDoll memberDoll; + + @OneToMany(mappedBy = "member") + private final List dailyRoutines = new ArrayList<>(); + + @OneToOne + @JoinColumn(name = "happiness_routine_id") + private MemberHappinessRoutine happinessRoutine; + + @Builder + public Member(SocialType socialType, String socialId) { + this.socialType = socialType; + this.socialId = socialId; + this.cottonInfo = new Cotton(0, 0); + } + + public void setMemberDoll(MemberDoll memberDoll) { + this.memberDoll = memberDoll; + } + + public void resetHappinessRoutine() { + this.happinessRoutine = null; + } + + public void addHappinessRoutine(MemberHappinessRoutine happinessRoutine) { + this.happinessRoutine = happinessRoutine; + } + + public void updateRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + public void resetRefreshToken() { + this.refreshToken = null; + } + + public void addDailyCotton() { + this.cottonInfo.addDailyCotton(); + } + + public void addHappinessCotton() { + this.cottonInfo.addHappinessCotton(); + } + + public int subtractAndGetCotton(CottonType type) { + if (Objects.isNull(this.memberDoll)) { + throw new MemberDollException(NOT_EXIST_MEMBER_DOLL); + } + return cottonInfo.subtractAndGetCotton(type, this.memberDoll); + } + + public void checkMemberDollExist() { + if (!isMemberDollExist()) { + throw new MemberException(NOT_EXIST_DOLL); + } + } + + public void checkMemberDollNonExist() { + if (isMemberDollExist()) { + throw new MemberException(EXIST_PROFILE); + } + } + + public boolean isMemberDollExist() { + return Objects.nonNull(this.getMemberDoll()); + } + + public void checkDailyRoutineAddition() { + if (this.getDailyRoutines().size() >= DAILY_ROUTINE_MAX_COUNT) { + throw new RoutineException(CANNOT_ADD_MEMBER_ROUTINE); + } + } + + public void checkHappinessRoutineAddition() { + if (Objects.nonNull(this.getHappinessRoutine())) { + throw new RoutineException(CANNOT_ADD_MEMBER_ROUTINE); + } + } + + public void checkDailyRoutineForMember(MemberDailyRoutine routine) { + if (!isDailyRoutineForMember(routine)) { + throw new MemberException(INACCESSIBLE_ROUTINE); + } + } + + public boolean isDailyRoutineForMember(MemberDailyRoutine routine) { + return this.getDailyRoutines().contains(routine); + } + + public void checkHappinessRoutineForMember(MemberHappinessRoutine routine) { + if (!this.getHappinessRoutine().equals(routine)) { + throw new MemberException(INACCESSIBLE_ROUTINE); + } + } + + public boolean isNotExistRoutine(DailyRoutine routine) { + return this.getDailyRoutines().stream() + .noneMatch(memberRoutine -> memberRoutine.getRoutine().equals(routine)); + } +} diff --git a/src/main/java/com/soptie/server/member/entity/SocialType.java b/src/main/java/com/soptie/server/member/entity/SocialType.java new file mode 100644 index 00000000..b1900063 --- /dev/null +++ b/src/main/java/com/soptie/server/member/entity/SocialType.java @@ -0,0 +1,5 @@ +package com.soptie.server.member.entity; + +public enum SocialType { + KAKAO, APPLE +} diff --git a/src/main/java/com/soptie/server/member/exception/MemberException.java b/src/main/java/com/soptie/server/member/exception/MemberException.java new file mode 100644 index 00000000..4d10ee9d --- /dev/null +++ b/src/main/java/com/soptie/server/member/exception/MemberException.java @@ -0,0 +1,16 @@ +package com.soptie.server.member.exception; + +import com.soptie.server.member.message.ErrorCode; + +import lombok.Getter; + +@Getter +public class MemberException extends RuntimeException { + + private final ErrorCode errorCode; + + public MemberException(ErrorCode errorCode) { + super("[MemberException] : " + errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/soptie/server/member/message/ErrorCode.java b/src/main/java/com/soptie/server/member/message/ErrorCode.java new file mode 100644 index 00000000..90c06ccd --- /dev/null +++ b/src/main/java/com/soptie/server/member/message/ErrorCode.java @@ -0,0 +1,28 @@ +package com.soptie.server.member.message; + +import static org.springframework.http.HttpStatus.*; + +import org.springframework.http.HttpStatus; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum ErrorCode { + + /* 400 BAD_REQUEST : 잘못된 요청 */ + INACCESSIBLE_ROUTINE(BAD_REQUEST, "회원의 루틴이 아닙니다."), + NOT_ENOUGH_COTTON(BAD_REQUEST, "솜뭉치가 부족합니다."), + + /* 404 NOT_FOUND : 자원을 찾을 수 없음 */ + INVALID_MEMBER(NOT_FOUND, "유효하지 않은 회원입니다."), + NOT_EXIST_DOLL(NOT_FOUND, "인형을 가지고 있지 않은 회원입니다."), + + /* 409 CONFLICT : 중복된 데이터 존재 */ + EXIST_PROFILE(CONFLICT, "프로필이 이미 존재합니다."), + ; + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/soptie/server/member/message/SuccessMessage.java b/src/main/java/com/soptie/server/member/message/SuccessMessage.java new file mode 100644 index 00000000..9b51e8b3 --- /dev/null +++ b/src/main/java/com/soptie/server/member/message/SuccessMessage.java @@ -0,0 +1,16 @@ +package com.soptie.server.member.message; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public enum SuccessMessage { + + SUCCESS_CREATE_PROFILE("프로필 생성 성공"), + SUCCESS_GIVE_COTTON("솜뭉치 주기 성공"), + SUCCESS_HOME_INFO("홈 화면 불러오기 성공") + ; + + private final String message; +} diff --git a/src/main/java/com/soptie/server/member/repository/MemberRepository.java b/src/main/java/com/soptie/server/member/repository/MemberRepository.java new file mode 100644 index 00000000..1ec5716c --- /dev/null +++ b/src/main/java/com/soptie/server/member/repository/MemberRepository.java @@ -0,0 +1,12 @@ +package com.soptie.server.member.repository; + +import com.soptie.server.member.entity.Member; +import com.soptie.server.member.entity.SocialType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberRepository extends JpaRepository { + Optional findBySocialTypeAndSocialId(SocialType socialType, String socialId); + Optional findByRefreshToken(String refreshToken); +} diff --git a/src/main/java/com/soptie/server/member/service/MemberService.java b/src/main/java/com/soptie/server/member/service/MemberService.java new file mode 100644 index 00000000..9358d6db --- /dev/null +++ b/src/main/java/com/soptie/server/member/service/MemberService.java @@ -0,0 +1,15 @@ +package com.soptie.server.member.service; + +import com.soptie.server.member.dto.CottonCountResponse; +import com.soptie.server.member.dto.MemberHomeInfoResponse; +import com.soptie.server.member.dto.MemberProfileRequest; +import com.soptie.server.member.entity.CottonType; +import com.soptie.server.member.entity.Member; + +public interface MemberService { + + void createMemberProfile(long memberId, MemberProfileRequest request); + CottonCountResponse giveCotton(long memberId, CottonType cottonType); + MemberHomeInfoResponse getMemberHomeInfo(long memberId); + void deleteMember(Member member); +} diff --git a/src/main/java/com/soptie/server/member/service/MemberServiceImpl.java b/src/main/java/com/soptie/server/member/service/MemberServiceImpl.java new file mode 100644 index 00000000..f3150aae --- /dev/null +++ b/src/main/java/com/soptie/server/member/service/MemberServiceImpl.java @@ -0,0 +1,73 @@ +package com.soptie.server.member.service; + +import com.soptie.server.conversation.entity.Conversation; +import com.soptie.server.conversation.repository.ConversationRepository; +import com.soptie.server.member.dto.CottonCountResponse; +import com.soptie.server.member.dto.MemberHomeInfoResponse; +import com.soptie.server.member.dto.MemberProfileRequest; +import com.soptie.server.member.entity.CottonType; +import com.soptie.server.member.entity.Member; +import com.soptie.server.member.exception.MemberException; +import com.soptie.server.member.repository.MemberRepository; +import com.soptie.server.memberDoll.service.MemberDollService; +import com.soptie.server.memberRoutine.service.MemberDailyRoutineService; +import lombok.RequiredArgsConstructor; +import lombok.val; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static com.soptie.server.member.message.ErrorCode.*; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MemberServiceImpl implements MemberService { + + private final MemberDollService memberDollService; + private final MemberDailyRoutineService memberDailyRoutineService; + private final MemberRepository memberRepository; + private final ConversationRepository conversationRepository; + + @Override + @Transactional + public void createMemberProfile(long memberId, MemberProfileRequest request) { + val member = findMember(memberId); + member.checkMemberDollNonExist(); + memberDailyRoutineService.createMemberDailyRoutines(member, request.routines()); + memberDollService.createMemberDoll(member, request.dollType(), request.name()); + } + + @Override + @Transactional + public CottonCountResponse giveCotton(long memberId, CottonType cottonType) { + val member = findMember(memberId); + val cottonCount = member.subtractAndGetCotton(cottonType); + return CottonCountResponse.of(cottonCount); + } + + @Override + public MemberHomeInfoResponse getMemberHomeInfo(long memberId) { + val member = findMember(memberId); + member.checkMemberDollExist(); + val conversations = getConversations(); + return MemberHomeInfoResponse.of(member, conversations); + } + + @Override + public void deleteMember(Member member) { + memberRepository.delete(member); + } + + private Member findMember(long id) { + return memberRepository.findById(id) + .orElseThrow(() -> new MemberException(INVALID_MEMBER)); + } + + private List getConversations() { + return conversationRepository.findAll().stream() + .map(Conversation::getContent) + .toList(); + } +} diff --git a/src/main/java/com/soptie/server/memberDoll/entity/MemberDoll.java b/src/main/java/com/soptie/server/memberDoll/entity/MemberDoll.java new file mode 100644 index 00000000..bb4f6c9f --- /dev/null +++ b/src/main/java/com/soptie/server/memberDoll/entity/MemberDoll.java @@ -0,0 +1,66 @@ +package com.soptie.server.memberDoll.entity; + +import com.soptie.server.common.entity.BaseTime; +import com.soptie.server.doll.entity.Doll; +import com.soptie.server.member.entity.Member; + +import com.soptie.server.memberDoll.exception.MemberDollException; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import static com.soptie.server.common.config.ValueConfig.*; +import static com.soptie.server.doll.message.ErrorCode.*; + +@Entity +@NoArgsConstructor +@Getter +public class MemberDoll extends BaseTime { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_doll_id") + private Long id; + + private String name; + + private int happinessCottonCount; + + @OneToOne(mappedBy = "memberDoll") + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "doll_id") + private Doll doll; + + public MemberDoll(Member member, Doll doll, String name) { + this.happinessCottonCount = 0; + setMember(member); + this.doll = doll; + setName(name); + } + + private void setMember(Member member) { + this.member = member; + member.setMemberDoll(this); + } + + private void setName(String name) { + if (!name.matches(MEMBER_DOLL_CONDITION)) { + throw new MemberDollException(INVALID_NAME); + } + this.name = name; + } + + public void addHappinessCottonCount() { + this.happinessCottonCount++; + } +} diff --git a/src/main/java/com/soptie/server/memberDoll/exception/MemberDollException.java b/src/main/java/com/soptie/server/memberDoll/exception/MemberDollException.java new file mode 100644 index 00000000..a24c6aff --- /dev/null +++ b/src/main/java/com/soptie/server/memberDoll/exception/MemberDollException.java @@ -0,0 +1,15 @@ +package com.soptie.server.memberDoll.exception; + +import com.soptie.server.doll.message.ErrorCode; +import lombok.Getter; + +@Getter +public class MemberDollException extends RuntimeException { + + private final ErrorCode errorCode; + + public MemberDollException(ErrorCode errorCode) { + super("[MemberDollException] : " + errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/soptie/server/memberDoll/repository/MemberDollRepository.java b/src/main/java/com/soptie/server/memberDoll/repository/MemberDollRepository.java new file mode 100644 index 00000000..516f29d9 --- /dev/null +++ b/src/main/java/com/soptie/server/memberDoll/repository/MemberDollRepository.java @@ -0,0 +1,7 @@ +package com.soptie.server.memberDoll.repository; + +import com.soptie.server.memberDoll.entity.MemberDoll; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberDollRepository extends JpaRepository { +} diff --git a/src/main/java/com/soptie/server/memberDoll/service/MemberDollService.java b/src/main/java/com/soptie/server/memberDoll/service/MemberDollService.java new file mode 100644 index 00000000..ab7e46ea --- /dev/null +++ b/src/main/java/com/soptie/server/memberDoll/service/MemberDollService.java @@ -0,0 +1,11 @@ +package com.soptie.server.memberDoll.service; + +import com.soptie.server.doll.entity.DollType; +import com.soptie.server.member.entity.Member; +import com.soptie.server.memberDoll.entity.MemberDoll; + +public interface MemberDollService { + + void createMemberDoll(Member member, DollType dollType, String name); + void deleteMemberDoll(MemberDoll memberDoll); +} diff --git a/src/main/java/com/soptie/server/memberDoll/service/MemberDollServiceImpl.java b/src/main/java/com/soptie/server/memberDoll/service/MemberDollServiceImpl.java new file mode 100644 index 00000000..7e010c1d --- /dev/null +++ b/src/main/java/com/soptie/server/memberDoll/service/MemberDollServiceImpl.java @@ -0,0 +1,43 @@ +package com.soptie.server.memberDoll.service; + +import com.soptie.server.doll.entity.Doll; +import com.soptie.server.doll.entity.DollType; +import com.soptie.server.doll.exception.DollException; +import com.soptie.server.doll.repository.DollRepository; +import com.soptie.server.member.entity.Member; +import com.soptie.server.memberDoll.entity.MemberDoll; +import com.soptie.server.memberDoll.repository.MemberDollRepository; +import lombok.RequiredArgsConstructor; +import lombok.val; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import static com.soptie.server.doll.message.ErrorCode.INVALID_TYPE; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MemberDollServiceImpl implements MemberDollService { + + private final MemberDollRepository memberDollRepository; + private final DollRepository dollRepository; + + @Override + @Transactional + public void createMemberDoll(Member member, DollType dollType, String name) { + val doll = findDoll(dollType); + val memberDoll = new MemberDoll(member, doll, name); + memberDollRepository.save(memberDoll); + } + + @Override + @Transactional + public void deleteMemberDoll(MemberDoll memberDoll) { + memberDollRepository.delete(memberDoll); + } + + private Doll findDoll(DollType type) { + return dollRepository.findByDollType(type) + .orElseThrow(() -> new DollException(INVALID_TYPE)); + } +} diff --git a/src/main/java/com/soptie/server/memberRoutine/controller/MemberDailyRoutineController.java b/src/main/java/com/soptie/server/memberRoutine/controller/MemberDailyRoutineController.java new file mode 100644 index 00000000..35521172 --- /dev/null +++ b/src/main/java/com/soptie/server/memberRoutine/controller/MemberDailyRoutineController.java @@ -0,0 +1,74 @@ +package com.soptie.server.memberRoutine.controller; + +import static com.soptie.server.common.dto.Response.*; +import static com.soptie.server.memberRoutine.message.SuccessMessage.*; + +import java.net.URI; +import java.security.Principal; +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +import com.soptie.server.common.dto.Response; +import com.soptie.server.memberRoutine.service.MemberDailyRoutineService; +import com.soptie.server.memberRoutine.dto.MemberDailyRoutineRequest; + +import lombok.RequiredArgsConstructor; +import lombok.val; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/routines/daily/member") +public class MemberDailyRoutineController { + + private final MemberDailyRoutineService memberDailyRoutineService; + + @PostMapping + public ResponseEntity createMemberDailyRoutine( + Principal principal, @RequestBody MemberDailyRoutineRequest request) { + val memberId = Long.parseLong(principal.getName()); + val response = memberDailyRoutineService.createMemberDailyRoutine(memberId, request); + return ResponseEntity + .created(getURI()) + .body(success(SUCCESS_CREATE_ROUTINE.getMessage(), response)); + } + + private URI getURI() { + return ServletUriComponentsBuilder + .fromCurrentRequest() + .path("/") + .buildAndExpand() + .toUri(); + } + + @DeleteMapping + public ResponseEntity deleteMemberDailyRoutines(Principal principal, @RequestParam List routines) { + val memberId = Long.parseLong(principal.getName()); + memberDailyRoutineService.deleteMemberDailyRoutines(memberId, routines); + return ResponseEntity.ok(success(SUCCESS_DELETE_ROUTINE.getMessage())); + } + + @PatchMapping("/routine/{routineId}") + public ResponseEntity achieveMemberDailyRoutine(Principal principal, @PathVariable Long routineId) { + val memberId = Long.parseLong(principal.getName()); + val response = memberDailyRoutineService.achieveMemberDailyRoutine(memberId, routineId); + return ResponseEntity.ok(success(SUCCESS_ACHIEVE_ROUTINE.getMessage(), response)); + } + + @GetMapping + public ResponseEntity getMemberDailyRoutines(Principal principal) { + val memberId = Long.parseLong(principal.getName()); + val response = memberDailyRoutineService.getMemberDailyRoutines(memberId); + return ResponseEntity.ok(success(SUCCESS_GET_ROUTINE.getMessage(), response)); + } +} diff --git a/src/main/java/com/soptie/server/memberRoutine/controller/MemberHappinessRoutineController.java b/src/main/java/com/soptie/server/memberRoutine/controller/MemberHappinessRoutineController.java new file mode 100644 index 00000000..1fbaad89 --- /dev/null +++ b/src/main/java/com/soptie/server/memberRoutine/controller/MemberHappinessRoutineController.java @@ -0,0 +1,68 @@ +package com.soptie.server.memberRoutine.controller; + +import com.soptie.server.common.dto.Response; +import com.soptie.server.memberRoutine.dto.MemberHappinessRoutineRequest; +import com.soptie.server.memberRoutine.service.MemberHappinessRoutineService; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.val; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +import java.net.URI; +import java.security.Principal; + +import static com.soptie.server.common.dto.Response.success; +import static com.soptie.server.memberRoutine.message.SuccessMessage.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/routines/happiness/member") +public class MemberHappinessRoutineController { + + private final MemberHappinessRoutineService memberHappinessRoutineService; + + @PostMapping + public ResponseEntity createMemberHappinessRoutine( + Principal principal, @RequestBody MemberHappinessRoutineRequest request) { + val memberId = Long.parseLong(principal.getName()); + val response = memberHappinessRoutineService.createMemberHappinessRoutine(memberId, request); + return ResponseEntity + .created(getURI()) + .body(success(SUCCESS_CREATE_ROUTINE.getMessage(), response)); + } + + private URI getURI() { + return ServletUriComponentsBuilder + .fromCurrentRequest() + .path("/") + .buildAndExpand() + .toUri(); + } + + @GetMapping + public ResponseEntity getMemberHappinessRoutine(@NonNull Principal principal) { + val memberId = Long.parseLong(principal.getName()); + val response = memberHappinessRoutineService.getMemberHappinessRoutine(memberId); + return response + .map(result -> ResponseEntity.ok(success(SUCCESS_GET_ROUTINE.getMessage(), result))) + .orElseGet(() -> ResponseEntity.noContent().build()); + } + + @DeleteMapping("/routine/{routineId}") + public ResponseEntity deleteMemberHappinessRoutine(Principal principal, @PathVariable Long routineId) { + val memberId = Long.parseLong(principal.getName()); + memberHappinessRoutineService.deleteMemberHappinessRoutine(memberId, routineId); + return ResponseEntity.ok(success(SUCCESS_DELETE_ROUTINE.getMessage())); + } + + @PatchMapping("/routine/{routineId}") + public ResponseEntity achieveMemberHappinessRoutine(Principal principal, @PathVariable Long routineId){ + val memberId = Long.parseLong(principal.getName()); + memberHappinessRoutineService.achieveMemberHappinessRoutine(memberId, routineId); + return ResponseEntity.ok(success(SUCCESS_ACHIEVE_ROUTINE.getMessage())); + } +} diff --git a/src/main/java/com/soptie/server/memberRoutine/dto/AchievedMemberDailyRoutineResponse.java b/src/main/java/com/soptie/server/memberRoutine/dto/AchievedMemberDailyRoutineResponse.java new file mode 100644 index 00000000..8b0d0756 --- /dev/null +++ b/src/main/java/com/soptie/server/memberRoutine/dto/AchievedMemberDailyRoutineResponse.java @@ -0,0 +1,14 @@ +package com.soptie.server.memberRoutine.dto; + +import com.soptie.server.memberRoutine.entity.daily.MemberDailyRoutine; + +public record AchievedMemberDailyRoutineResponse( + long routineId, + boolean isAchieve, + int achieveCount +) { + + public static AchievedMemberDailyRoutineResponse of(MemberDailyRoutine routine) { + return new AchievedMemberDailyRoutineResponse(routine.getId(), routine.isAchieve(), routine.getAchieveCount()); + } +} diff --git a/src/main/java/com/soptie/server/memberRoutine/dto/MemberDailyRoutineRequest.java b/src/main/java/com/soptie/server/memberRoutine/dto/MemberDailyRoutineRequest.java new file mode 100644 index 00000000..aacd6c59 --- /dev/null +++ b/src/main/java/com/soptie/server/memberRoutine/dto/MemberDailyRoutineRequest.java @@ -0,0 +1,6 @@ +package com.soptie.server.memberRoutine.dto; + +public record MemberDailyRoutineRequest( + long routineId +) { +} diff --git a/src/main/java/com/soptie/server/memberRoutine/dto/MemberDailyRoutineResponse.java b/src/main/java/com/soptie/server/memberRoutine/dto/MemberDailyRoutineResponse.java new file mode 100644 index 00000000..49cd282f --- /dev/null +++ b/src/main/java/com/soptie/server/memberRoutine/dto/MemberDailyRoutineResponse.java @@ -0,0 +1,12 @@ +package com.soptie.server.memberRoutine.dto; + +import com.soptie.server.memberRoutine.entity.daily.MemberDailyRoutine; + +public record MemberDailyRoutineResponse( + long routineId +) { + + public static MemberDailyRoutineResponse of(MemberDailyRoutine routine) { + return new MemberDailyRoutineResponse(routine.getId()); + } +} diff --git a/src/main/java/com/soptie/server/memberRoutine/dto/MemberDailyRoutinesResponse.java b/src/main/java/com/soptie/server/memberRoutine/dto/MemberDailyRoutinesResponse.java new file mode 100644 index 00000000..eb55cea1 --- /dev/null +++ b/src/main/java/com/soptie/server/memberRoutine/dto/MemberDailyRoutinesResponse.java @@ -0,0 +1,37 @@ +package com.soptie.server.memberRoutine.dto; + +import java.util.List; + +import com.soptie.server.memberRoutine.entity.daily.MemberDailyRoutine; + +import lombok.Builder; +import lombok.NonNull; + +public record MemberDailyRoutinesResponse( + @NonNull List routines +) { + + public static MemberDailyRoutinesResponse of(List routines) { + return new MemberDailyRoutinesResponse(routines.stream().map(MemberDailyRoutineResponse::of).toList()); + } + + @Builder + public record MemberDailyRoutineResponse( + long routineId, + @NonNull String content, + @NonNull String iconImageUrl, + int achieveCount, + boolean isAchieve + ) { + + private static MemberDailyRoutineResponse of(MemberDailyRoutine routine) { + return MemberDailyRoutineResponse.builder() + .routineId(routine.getId()) + .content(routine.getRoutine().getContent()) + .iconImageUrl(routine.getRoutine().getTheme().getImageInfo().getIconImageUrl()) + .achieveCount(routine.getAchieveCount()) + .isAchieve(routine.isAchieve()) + .build(); + } + } +} diff --git a/src/main/java/com/soptie/server/memberRoutine/dto/MemberHappinessRoutineRequest.java b/src/main/java/com/soptie/server/memberRoutine/dto/MemberHappinessRoutineRequest.java new file mode 100644 index 00000000..292d3081 --- /dev/null +++ b/src/main/java/com/soptie/server/memberRoutine/dto/MemberHappinessRoutineRequest.java @@ -0,0 +1,6 @@ +package com.soptie.server.memberRoutine.dto; + +public record MemberHappinessRoutineRequest( + long subRoutineId +) { +} diff --git a/src/main/java/com/soptie/server/memberRoutine/dto/MemberHappinessRoutineResponse.java b/src/main/java/com/soptie/server/memberRoutine/dto/MemberHappinessRoutineResponse.java new file mode 100644 index 00000000..19902d53 --- /dev/null +++ b/src/main/java/com/soptie/server/memberRoutine/dto/MemberHappinessRoutineResponse.java @@ -0,0 +1,11 @@ +package com.soptie.server.memberRoutine.dto; + +import com.soptie.server.memberRoutine.entity.happiness.MemberHappinessRoutine; + +public record MemberHappinessRoutineResponse( + long routineId +) { + public static MemberHappinessRoutineResponse of(MemberHappinessRoutine routine) { + return new MemberHappinessRoutineResponse(routine.getId()); + } +} diff --git a/src/main/java/com/soptie/server/memberRoutine/dto/MemberHappinessRoutinesResponse.java b/src/main/java/com/soptie/server/memberRoutine/dto/MemberHappinessRoutinesResponse.java new file mode 100644 index 00000000..b4bfa3cf --- /dev/null +++ b/src/main/java/com/soptie/server/memberRoutine/dto/MemberHappinessRoutinesResponse.java @@ -0,0 +1,36 @@ +package com.soptie.server.memberRoutine.dto; + +import com.soptie.server.memberRoutine.entity.happiness.MemberHappinessRoutine; + +import lombok.Builder; +import lombok.NonNull; + +@Builder +public record MemberHappinessRoutinesResponse( + long routineId, + @NonNull String iconImageUrl, + @NonNull String contentImageUrl, + @NonNull String themeName, + @NonNull String themeNameColor, + @NonNull String title, + @NonNull String content, + @NonNull String detailContent, + @NonNull String place, + @NonNull String timeTaken +) { + + public static MemberHappinessRoutinesResponse of(MemberHappinessRoutine routine) { + return MemberHappinessRoutinesResponse.builder() + .routineId(routine.getId()) + .iconImageUrl(routine.getRoutine().getRoutine().getTheme().getImageInfo().getIconImageUrl()) + .contentImageUrl(routine.getRoutine().getRoutine().getTheme().getImageInfo().getContentImageUrl()) + .themeName(routine.getRoutine().getRoutine().getTheme().getName()) + .themeNameColor(routine.getRoutine().getRoutine().getTheme().getNameColor()) + .title(routine.getRoutine().getRoutine().getTitle()) + .content(routine.getRoutine().getContent()) + .detailContent(routine.getRoutine().getDetailContent()) + .place(routine.getRoutine().getPlace()) + .timeTaken(routine.getRoutine().getTimeTaken()) + .build(); + } +} diff --git a/src/main/java/com/soptie/server/memberRoutine/entity/daily/CompletedMemberDailyRoutine.java b/src/main/java/com/soptie/server/memberRoutine/entity/daily/CompletedMemberDailyRoutine.java new file mode 100644 index 00000000..6abce638 --- /dev/null +++ b/src/main/java/com/soptie/server/memberRoutine/entity/daily/CompletedMemberDailyRoutine.java @@ -0,0 +1,60 @@ +package com.soptie.server.memberRoutine.entity.daily; + +import java.time.LocalDateTime; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import com.soptie.server.member.entity.Member; +import com.soptie.server.routine.entity.daily.DailyRoutine; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor +@Getter +@EntityListeners(AuditingEntityListener.class) +public class CompletedMemberDailyRoutine { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_routine_id") + private Long id; + + private int achieveCount; + + private Boolean isAchieve; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "routine_id") + private DailyRoutine routine; + + @CreatedDate + protected LocalDateTime createdAt; + + public CompletedMemberDailyRoutine(MemberDailyRoutine routine) { + this.achieveCount = routine.getAchieveCount(); + this.isAchieve = routine.isAchieve(); + setMember(routine); + this.routine = routine.getRoutine(); + } + + private void setMember(MemberDailyRoutine routine) { + routine.getMember().getDailyRoutines().remove(routine); + this.member = routine.getMember(); + } +} diff --git a/src/main/java/com/soptie/server/memberRoutine/entity/daily/MemberDailyRoutine.java b/src/main/java/com/soptie/server/memberRoutine/entity/daily/MemberDailyRoutine.java new file mode 100644 index 00000000..c5dc6079 --- /dev/null +++ b/src/main/java/com/soptie/server/memberRoutine/entity/daily/MemberDailyRoutine.java @@ -0,0 +1,79 @@ +package com.soptie.server.memberRoutine.entity.daily; + +import java.util.Objects; + +import com.soptie.server.member.entity.Member; +import com.soptie.server.routine.entity.daily.DailyRoutine; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor +@Getter +public class MemberDailyRoutine { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_routine_id") + private Long id; + + private int achieveCount; + + private boolean isAchieve; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "routine_id") + private DailyRoutine routine; + + public MemberDailyRoutine(Member member, DailyRoutine routine) { + this.achieveCount = 0; + this.isAchieve = false; + setMember(member); + this.routine = routine; + } + + public MemberDailyRoutine(Member member, DailyRoutine routine, int achieveCount) { + this.achieveCount = achieveCount; + this.isAchieve = false; + setMember(member); + this.routine = routine; + } + + public MemberDailyRoutine(Member member, DailyRoutine routine, int achieveCount, boolean isAchieve) { + this.achieveCount = achieveCount; + this.isAchieve = isAchieve; + setMember(member); + this.routine = routine; + } + + private void setMember(Member member) { + if (Objects.nonNull(this.member)) { + this.member.getDailyRoutines().remove(this); + } + this.member = member; + member.getDailyRoutines().add(this); + } + + public void achieveRoutine() { + this.isAchieve = true; + this.achieveCount++; + this.member.addDailyCotton(); + } + + public void initAchievement() { + this.isAchieve = false; + } +} diff --git a/src/main/java/com/soptie/server/memberRoutine/entity/happiness/MemberHappinessRoutine.java b/src/main/java/com/soptie/server/memberRoutine/entity/happiness/MemberHappinessRoutine.java new file mode 100644 index 00000000..69321aca --- /dev/null +++ b/src/main/java/com/soptie/server/memberRoutine/entity/happiness/MemberHappinessRoutine.java @@ -0,0 +1,49 @@ +package com.soptie.server.memberRoutine.entity.happiness; + +import java.util.Objects; + +import com.soptie.server.member.entity.Member; +import com.soptie.server.routine.entity.happiness.HappinessSubRoutine; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor +@Getter +public class MemberHappinessRoutine { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_routine_id") + private Long id; + + @OneToOne(mappedBy = "happinessRoutine") + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "routine_id") + private HappinessSubRoutine routine; + + public MemberHappinessRoutine(Member member, HappinessSubRoutine routine) { + setMember(member); + this.routine = routine; + } + + private void setMember(Member member) { + if (Objects.nonNull(this.member)) { + this.member.resetHappinessRoutine(); + } + this.member = member; + member.addHappinessRoutine(this); + } +} diff --git a/src/main/java/com/soptie/server/memberRoutine/message/SuccessMessage.java b/src/main/java/com/soptie/server/memberRoutine/message/SuccessMessage.java new file mode 100644 index 00000000..c9f939b7 --- /dev/null +++ b/src/main/java/com/soptie/server/memberRoutine/message/SuccessMessage.java @@ -0,0 +1,16 @@ +package com.soptie.server.memberRoutine.message; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public enum SuccessMessage { + SUCCESS_CREATE_ROUTINE("루틴 추가 성공"), + SUCCESS_DELETE_ROUTINE("루틴 삭제 성공"), + SUCCESS_ACHIEVE_ROUTINE("루틴 달성 성공"), + SUCCESS_GET_ROUTINE("루틴 조회 성공"), + ; + + private final String message; +} diff --git a/src/main/java/com/soptie/server/memberRoutine/repository/CompletedMemberDailyRoutineRepository.java b/src/main/java/com/soptie/server/memberRoutine/repository/CompletedMemberDailyRoutineRepository.java new file mode 100644 index 00000000..1931bfbb --- /dev/null +++ b/src/main/java/com/soptie/server/memberRoutine/repository/CompletedMemberDailyRoutineRepository.java @@ -0,0 +1,14 @@ +package com.soptie.server.memberRoutine.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.soptie.server.member.entity.Member; +import com.soptie.server.memberRoutine.entity.daily.CompletedMemberDailyRoutine; +import com.soptie.server.routine.entity.daily.DailyRoutine; + +public interface CompletedMemberDailyRoutineRepository extends JpaRepository { + Optional findByMemberAndRoutine(Member member, DailyRoutine routine); + void deleteAllByMember(Member member); +} diff --git a/src/main/java/com/soptie/server/memberRoutine/repository/MemberDailyRoutineCustomRepository.java b/src/main/java/com/soptie/server/memberRoutine/repository/MemberDailyRoutineCustomRepository.java new file mode 100644 index 00000000..6f7b9d4d --- /dev/null +++ b/src/main/java/com/soptie/server/memberRoutine/repository/MemberDailyRoutineCustomRepository.java @@ -0,0 +1,11 @@ +package com.soptie.server.memberRoutine.repository; + +import java.util.List; + +import com.soptie.server.member.entity.Member; +import com.soptie.server.memberRoutine.entity.daily.MemberDailyRoutine; + +public interface MemberDailyRoutineCustomRepository { + List findAllByMember(Member member); + List findAllByAchieved(); +} diff --git a/src/main/java/com/soptie/server/memberRoutine/repository/MemberDailyRoutineRepository.java b/src/main/java/com/soptie/server/memberRoutine/repository/MemberDailyRoutineRepository.java new file mode 100644 index 00000000..bc9f1b4b --- /dev/null +++ b/src/main/java/com/soptie/server/memberRoutine/repository/MemberDailyRoutineRepository.java @@ -0,0 +1,10 @@ +package com.soptie.server.memberRoutine.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.soptie.server.member.entity.Member; +import com.soptie.server.memberRoutine.entity.daily.MemberDailyRoutine; +import com.soptie.server.routine.entity.daily.DailyRoutine; + +public interface MemberDailyRoutineRepository extends JpaRepository, MemberDailyRoutineCustomRepository { +} diff --git a/src/main/java/com/soptie/server/memberRoutine/repository/MemberDailyRoutineRepositoryImpl.java b/src/main/java/com/soptie/server/memberRoutine/repository/MemberDailyRoutineRepositoryImpl.java new file mode 100644 index 00000000..489f6360 --- /dev/null +++ b/src/main/java/com/soptie/server/memberRoutine/repository/MemberDailyRoutineRepositoryImpl.java @@ -0,0 +1,48 @@ +package com.soptie.server.memberRoutine.repository; + +import static com.soptie.server.memberRoutine.entity.daily.QMemberDailyRoutine.*; +import static com.soptie.server.routine.entity.daily.QDailyRoutine.*; +import static com.soptie.server.routine.entity.daily.QDailyTheme.*; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.soptie.server.member.entity.Member; +import com.soptie.server.memberRoutine.entity.daily.MemberDailyRoutine; + +import lombok.RequiredArgsConstructor; +import lombok.val; + +@Repository +@RequiredArgsConstructor +public class MemberDailyRoutineRepositoryImpl implements MemberDailyRoutineCustomRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public List findAllByMember(Member member) { + val contentInKRExpression = Expressions.stringTemplate("SUBSTR({0}, 1, 1)", memberDailyRoutine.routine.content); + return queryFactory + .selectFrom(memberDailyRoutine) + .where(memberDailyRoutine.member.eq(member)) + .leftJoin(memberDailyRoutine.routine, dailyRoutine).fetchJoin() + .leftJoin(dailyRoutine.theme, dailyTheme).fetchJoin() + .orderBy( + memberDailyRoutine.isAchieve.asc(), + memberDailyRoutine.achieveCount.desc(), + contentInKRExpression.asc() + ) + .fetch(); + } + + @Override + public List findAllByAchieved() { + return queryFactory + .selectFrom(memberDailyRoutine) + .where(memberDailyRoutine.isAchieve.isTrue()) + .fetch(); + } +} diff --git a/src/main/java/com/soptie/server/memberRoutine/repository/MemberHappinessRoutineRepository.java b/src/main/java/com/soptie/server/memberRoutine/repository/MemberHappinessRoutineRepository.java new file mode 100644 index 00000000..eca3a8df --- /dev/null +++ b/src/main/java/com/soptie/server/memberRoutine/repository/MemberHappinessRoutineRepository.java @@ -0,0 +1,7 @@ +package com.soptie.server.memberRoutine.repository; + +import com.soptie.server.memberRoutine.entity.happiness.MemberHappinessRoutine; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberHappinessRoutineRepository extends JpaRepository { +} diff --git a/src/main/java/com/soptie/server/memberRoutine/scheduler/MemberDailyRoutineScheduler.java b/src/main/java/com/soptie/server/memberRoutine/scheduler/MemberDailyRoutineScheduler.java new file mode 100644 index 00000000..eb552071 --- /dev/null +++ b/src/main/java/com/soptie/server/memberRoutine/scheduler/MemberDailyRoutineScheduler.java @@ -0,0 +1,22 @@ +package com.soptie.server.memberRoutine.scheduler; + +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import com.soptie.server.memberRoutine.service.MemberDailyRoutineService; + +import lombok.RequiredArgsConstructor; + +@Component +@EnableScheduling +@RequiredArgsConstructor +public class MemberDailyRoutineScheduler { + + private final MemberDailyRoutineService memberDailyRoutineService; + + @Scheduled(cron = "${softie.cron.init.routine}") + public void initMemberDailyRoutines() { + memberDailyRoutineService.initMemberDailyRoutines(); + } +} diff --git a/src/main/java/com/soptie/server/memberRoutine/service/CompletedMemberDailyRoutineService.java b/src/main/java/com/soptie/server/memberRoutine/service/CompletedMemberDailyRoutineService.java new file mode 100644 index 00000000..47908152 --- /dev/null +++ b/src/main/java/com/soptie/server/memberRoutine/service/CompletedMemberDailyRoutineService.java @@ -0,0 +1,8 @@ +package com.soptie.server.memberRoutine.service; + +import com.soptie.server.member.entity.Member; + +public interface CompletedMemberDailyRoutineService { + + void deleteCompletedMemberDailyRoutines(Member member); +} diff --git a/src/main/java/com/soptie/server/memberRoutine/service/CompletedMemberDailyRoutineServiceImpl.java b/src/main/java/com/soptie/server/memberRoutine/service/CompletedMemberDailyRoutineServiceImpl.java new file mode 100644 index 00000000..e1a46aaa --- /dev/null +++ b/src/main/java/com/soptie/server/memberRoutine/service/CompletedMemberDailyRoutineServiceImpl.java @@ -0,0 +1,21 @@ +package com.soptie.server.memberRoutine.service; + +import com.soptie.server.member.entity.Member; +import com.soptie.server.memberRoutine.repository.CompletedMemberDailyRoutineRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CompletedMemberDailyRoutineServiceImpl implements CompletedMemberDailyRoutineService { + + private final CompletedMemberDailyRoutineRepository completedMemberDailyRoutineRepository; + + @Override + @Transactional + public void deleteCompletedMemberDailyRoutines(Member member) { + completedMemberDailyRoutineRepository.deleteAllByMember(member); + } +} diff --git a/src/main/java/com/soptie/server/memberRoutine/service/MemberDailyRoutineService.java b/src/main/java/com/soptie/server/memberRoutine/service/MemberDailyRoutineService.java new file mode 100644 index 00000000..48ed47e6 --- /dev/null +++ b/src/main/java/com/soptie/server/memberRoutine/service/MemberDailyRoutineService.java @@ -0,0 +1,21 @@ +package com.soptie.server.memberRoutine.service; + +import com.soptie.server.member.entity.Member; +import com.soptie.server.memberRoutine.dto.AchievedMemberDailyRoutineResponse; +import com.soptie.server.memberRoutine.dto.MemberDailyRoutineRequest; +import com.soptie.server.memberRoutine.dto.MemberDailyRoutineResponse; +import com.soptie.server.memberRoutine.dto.MemberDailyRoutinesResponse; +import com.soptie.server.memberRoutine.entity.daily.MemberDailyRoutine; + +import java.util.List; + +public interface MemberDailyRoutineService { + + MemberDailyRoutineResponse createMemberDailyRoutine(long memberId, MemberDailyRoutineRequest request); + void createMemberDailyRoutines(Member member, List routines); + void deleteMemberDailyRoutines(long memberId, List routineIds); + AchievedMemberDailyRoutineResponse achieveMemberDailyRoutine(long memberId, Long routineId); + MemberDailyRoutinesResponse getMemberDailyRoutines(long memberId); + void initMemberDailyRoutines(); + void deleteMemberDailyRoutine(MemberDailyRoutine routine); +} diff --git a/src/main/java/com/soptie/server/memberRoutine/service/MemberDailyRoutineServiceImpl.java b/src/main/java/com/soptie/server/memberRoutine/service/MemberDailyRoutineServiceImpl.java new file mode 100644 index 00000000..4ddb10ab --- /dev/null +++ b/src/main/java/com/soptie/server/memberRoutine/service/MemberDailyRoutineServiceImpl.java @@ -0,0 +1,163 @@ +package com.soptie.server.memberRoutine.service; + +import static com.soptie.server.member.message.ErrorCode.*; +import static com.soptie.server.routine.message.ErrorCode.*; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.soptie.server.member.entity.Member; +import com.soptie.server.member.exception.MemberException; +import com.soptie.server.member.repository.MemberRepository; +import com.soptie.server.memberRoutine.dto.AchievedMemberDailyRoutineResponse; +import com.soptie.server.memberRoutine.dto.MemberDailyRoutineRequest; +import com.soptie.server.memberRoutine.dto.MemberDailyRoutineResponse; +import com.soptie.server.memberRoutine.dto.MemberDailyRoutinesResponse; +import com.soptie.server.memberRoutine.entity.daily.CompletedMemberDailyRoutine; +import com.soptie.server.memberRoutine.entity.daily.MemberDailyRoutine; +import com.soptie.server.memberRoutine.repository.CompletedMemberDailyRoutineRepository; +import com.soptie.server.memberRoutine.repository.MemberDailyRoutineRepository; +import com.soptie.server.routine.entity.daily.DailyRoutine; +import com.soptie.server.routine.exception.RoutineException; +import com.soptie.server.routine.repository.daily.routine.DailyRoutineRepository; + +import lombok.*; + +import java.time.LocalDate; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MemberDailyRoutineServiceImpl implements MemberDailyRoutineService { + + private final MemberDailyRoutineRepository memberDailyRoutineRepository; + private final MemberRepository memberRepository; + private final DailyRoutineRepository dailyRoutineRepository; + private final CompletedMemberDailyRoutineRepository completedMemberDailyRoutineRepository; + + @Override + @Transactional + public MemberDailyRoutineResponse createMemberDailyRoutine(long memberId, MemberDailyRoutineRequest request) { + val member = findMember(memberId); + member.checkDailyRoutineAddition(); + val routine = findRoutine(request.routineId()); + val savedMemberRoutine = getMemberDailyRoutine(member, routine); + return MemberDailyRoutineResponse.of(savedMemberRoutine); + } + + private MemberDailyRoutine getMemberDailyRoutine(Member member, DailyRoutine routine) { + checkDuplicatedMemberRoutine(member, routine); + return completedMemberDailyRoutineRepository.findByMemberAndRoutine(member, routine) + .map(completedRoutine -> recreateOldRoutines(member, routine, completedRoutine)) + .orElseGet(() -> createNewRoutine(member, routine)); + } + + private void checkDuplicatedMemberRoutine(Member member, DailyRoutine routine) { + if (!member.isNotExistRoutine(routine)) { + throw new RoutineException(DUPLICATED_ROUTINE); + } + } + + private MemberDailyRoutine createNewRoutine(Member member, DailyRoutine routine) { + return memberDailyRoutineRepository.save(new MemberDailyRoutine(member, routine)); + } + + private MemberDailyRoutine recreateOldRoutines( + Member member, DailyRoutine routine, CompletedMemberDailyRoutine completedRoutine) { + val isTodayAchieved = isTodayAchieved(completedRoutine); + completedMemberDailyRoutineRepository.delete(completedRoutine); + val memberRoutine = new MemberDailyRoutine(member, routine, completedRoutine.getAchieveCount(), isTodayAchieved); + return memberDailyRoutineRepository.save(memberRoutine); + } + + private boolean isTodayAchieved(CompletedMemberDailyRoutine completedRoutine) { + return completedRoutine.getIsAchieve() && isTodayCompleted(completedRoutine); + } + + private boolean isTodayCompleted(CompletedMemberDailyRoutine completedRoutine) { + val now = LocalDate.now(); + return completedRoutine.getCreatedAt().toLocalDate().equals(now); + } + + @Override + @Transactional + public void createMemberDailyRoutines(Member member, List routines) { + routines.forEach(routineId -> memberDailyRoutineRepository + .save(new MemberDailyRoutine(member, findRoutine(routineId)))); + } + + private DailyRoutine findRoutine(long id) { + return dailyRoutineRepository.findById(id) + .orElseThrow(() -> new RoutineException(INVALID_ROUTINE)); + } + + @Override + @Transactional + public void deleteMemberDailyRoutines(long memberId, List routineIds) { + val member = findMember(memberId); + val routines = getMemberRoutines(member, routineIds); + deleteMemberRoutines(routines); + } + + private List getMemberRoutines(Member member, List routineIds) { + return routineIds.stream() + .map(this::findMemberRoutine) + .filter(member::isDailyRoutineForMember) + .toList(); + } + + private void deleteMemberRoutines(List routines) { + routines.forEach(this::deleteMemberRoutine); + } + + private void deleteMemberRoutine(MemberDailyRoutine routine) { + moveCompletedRoutine(routine); + memberDailyRoutineRepository.delete(routine); + } + + private void moveCompletedRoutine(MemberDailyRoutine routine) { + val completedRoutine = new CompletedMemberDailyRoutine(routine); + completedMemberDailyRoutineRepository.save(completedRoutine); + } + + @Override + @Transactional + public AchievedMemberDailyRoutineResponse achieveMemberDailyRoutine(long memberId, Long routineId) { + val member = findMember(memberId); + val routine = findMemberRoutine(routineId); + member.checkDailyRoutineForMember(routine); + routine.achieveRoutine(); + return AchievedMemberDailyRoutineResponse.of(routine); + } + + private MemberDailyRoutine findMemberRoutine(long id) { + return memberDailyRoutineRepository.findById(id) + .orElseThrow(() -> new RoutineException(INVALID_ROUTINE)); + } + + @Override + public MemberDailyRoutinesResponse getMemberDailyRoutines(long memberId) { + val member = findMember(memberId); + val routines = memberDailyRoutineRepository.findAllByMember(member); + return MemberDailyRoutinesResponse.of(routines); + } + + private Member findMember(long id) { + return memberRepository.findById(id) + .orElseThrow(() -> new MemberException(INVALID_MEMBER)); + } + + @Override + @Transactional + public void initMemberDailyRoutines() { + val routines = memberDailyRoutineRepository.findAllByAchieved(); + routines.forEach(MemberDailyRoutine::initAchievement); + } + + @Override + @Transactional + public void deleteMemberDailyRoutine(MemberDailyRoutine routine) { + memberDailyRoutineRepository.delete(routine); + } +} diff --git a/src/main/java/com/soptie/server/memberRoutine/service/MemberHappinessRoutineService.java b/src/main/java/com/soptie/server/memberRoutine/service/MemberHappinessRoutineService.java new file mode 100644 index 00000000..aa11fea7 --- /dev/null +++ b/src/main/java/com/soptie/server/memberRoutine/service/MemberHappinessRoutineService.java @@ -0,0 +1,15 @@ +package com.soptie.server.memberRoutine.service; + +import java.util.Optional; + +import com.soptie.server.memberRoutine.dto.*; +import com.soptie.server.memberRoutine.entity.happiness.MemberHappinessRoutine; + +public interface MemberHappinessRoutineService { + + MemberHappinessRoutineResponse createMemberHappinessRoutine(long memberId, MemberHappinessRoutineRequest request); + Optional getMemberHappinessRoutine(long memberId); + void deleteMemberHappinessRoutine(long memberId, long routineId); + void achieveMemberHappinessRoutine(long memberId, long routineId); + void deleteMemberHappinessRoutine(MemberHappinessRoutine routine); +} diff --git a/src/main/java/com/soptie/server/memberRoutine/service/MemberHappinessRoutineServiceImpl.java b/src/main/java/com/soptie/server/memberRoutine/service/MemberHappinessRoutineServiceImpl.java new file mode 100644 index 00000000..ebafb154 --- /dev/null +++ b/src/main/java/com/soptie/server/memberRoutine/service/MemberHappinessRoutineServiceImpl.java @@ -0,0 +1,100 @@ +package com.soptie.server.memberRoutine.service; + +import com.soptie.server.member.entity.Member; +import com.soptie.server.member.exception.MemberException; +import com.soptie.server.member.repository.MemberRepository; +import com.soptie.server.memberRoutine.dto.MemberHappinessRoutineRequest; +import com.soptie.server.memberRoutine.dto.MemberHappinessRoutineResponse; +import com.soptie.server.memberRoutine.dto.MemberHappinessRoutinesResponse; +import com.soptie.server.memberRoutine.entity.happiness.MemberHappinessRoutine; +import com.soptie.server.memberRoutine.repository.MemberHappinessRoutineRepository; +import com.soptie.server.routine.entity.happiness.HappinessSubRoutine; +import com.soptie.server.routine.exception.RoutineException; +import com.soptie.server.routine.repository.happiness.routine.HappinessSubRoutineRepository; +import lombok.RequiredArgsConstructor; +import lombok.val; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Objects; +import java.util.Optional; + +import static com.soptie.server.member.message.ErrorCode.*; +import static com.soptie.server.routine.message.ErrorCode.*; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MemberHappinessRoutineServiceImpl implements MemberHappinessRoutineService { + + private final MemberHappinessRoutineRepository memberHappinessRoutineRepository; + private final HappinessSubRoutineRepository happinessSubRoutineRepository; + private final MemberRepository memberRepository; + + @Override + @Transactional + public MemberHappinessRoutineResponse createMemberHappinessRoutine(long memberId, MemberHappinessRoutineRequest request) { + val member = findMember(memberId); + member.checkHappinessRoutineAddition(); + val routine = findRoutine(request.subRoutineId()); + val memberRoutine = new MemberHappinessRoutine(member, routine); + val savedMemberRoutine = memberHappinessRoutineRepository.save(memberRoutine); + return MemberHappinessRoutineResponse.of(savedMemberRoutine); + } + + private HappinessSubRoutine findRoutine(long id) { + return happinessSubRoutineRepository.findById(id) + .orElseThrow(() -> new RoutineException(INVALID_ROUTINE)); + } + + @Override + public Optional getMemberHappinessRoutine(long memberId) { + val member = findMember(memberId); + val memberRoutine = member.getHappinessRoutine(); + if (Objects.isNull(memberRoutine)) { + return Optional.empty(); + } + val response = MemberHappinessRoutinesResponse.of(memberRoutine); + return Optional.of(response); + } + + @Override + @Transactional + public void deleteMemberHappinessRoutine(long memberId, long routineId) { + val member = findMember(memberId); + val memberRoutine = findMemberRoutine(routineId); + member.checkHappinessRoutineForMember(memberRoutine); + deleteMemberRoutine(memberRoutine); + } + + private Member findMember(long id) { + return memberRepository.findById(id) + .orElseThrow(() -> new MemberException(INVALID_MEMBER)); + } + + private void deleteMemberRoutine(MemberHappinessRoutine routine) { + routine.getMember().resetHappinessRoutine(); + memberHappinessRoutineRepository.delete(routine); + } + + @Override + @Transactional + public void achieveMemberHappinessRoutine(long memberId, long routineId) { + val member = findMember(memberId); + val memberRoutine = findMemberRoutine(routineId); + member.checkHappinessRoutineForMember(memberRoutine); + member.addHappinessCotton(); + deleteMemberRoutine(memberRoutine); + } + + private MemberHappinessRoutine findMemberRoutine(long id) { + return memberHappinessRoutineRepository.findById(id) + .orElseThrow(() -> new RoutineException(INVALID_ROUTINE)); + } + + @Override + @Transactional + public void deleteMemberHappinessRoutine(MemberHappinessRoutine routine) { + memberHappinessRoutineRepository.delete(routine); + } +} diff --git a/src/main/java/com/soptie/server/routine/controller/DailyRoutineController.java b/src/main/java/com/soptie/server/routine/controller/DailyRoutineController.java new file mode 100644 index 00000000..f5fc7b94 --- /dev/null +++ b/src/main/java/com/soptie/server/routine/controller/DailyRoutineController.java @@ -0,0 +1,46 @@ +package com.soptie.server.routine.controller; + +import static com.soptie.server.common.dto.Response.*; +import static com.soptie.server.routine.message.SuccessMessage.*; + +import java.security.Principal; +import java.util.List; + +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; + +import com.soptie.server.common.dto.Response; +import com.soptie.server.routine.service.DailyRoutineService; + +import lombok.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/routines/daily") +public class DailyRoutineController { + + private final DailyRoutineService dailyRoutineService; + + @GetMapping("/themes") + public ResponseEntity getThemes() { + val response = dailyRoutineService.getThemes(); + return ResponseEntity.ok(success(SUCCESS_GET_THEME.getMessage(), response)); + } + + @GetMapping + public ResponseEntity getRoutinesByThemes(@RequestParam List themes) { + val response = dailyRoutineService.getRoutinesByThemes(themes); + return ResponseEntity.ok(success(SUCCESS_GET_ROUTINE.getMessage(), response)); + } + + @GetMapping("/theme/{themeId}") + public ResponseEntity getRoutinesByTheme(Principal principal, @PathVariable long themeId) { + val memberId = Long.valueOf(principal.getName()); + val response = dailyRoutineService.getRoutinesByTheme(memberId, themeId); + return ResponseEntity.ok(success(SUCCESS_GET_ROUTINE.getMessage(), response)); + } +} diff --git a/src/main/java/com/soptie/server/routine/controller/HappinessRoutineController.java b/src/main/java/com/soptie/server/routine/controller/HappinessRoutineController.java new file mode 100644 index 00000000..9de994d6 --- /dev/null +++ b/src/main/java/com/soptie/server/routine/controller/HappinessRoutineController.java @@ -0,0 +1,37 @@ +package com.soptie.server.routine.controller; + +import com.soptie.server.common.dto.Response; +import com.soptie.server.routine.service.HappinessRoutineService; +import lombok.RequiredArgsConstructor; +import lombok.val; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import static com.soptie.server.common.dto.Response.success; +import static com.soptie.server.routine.message.SuccessMessage.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/routines/happiness") +public class HappinessRoutineController { + + private final HappinessRoutineService happinessRoutineService; + + @GetMapping("/themes") + public ResponseEntity getHappinessThemes() { + val response = happinessRoutineService.getHappinessThemes(); + return ResponseEntity.ok(success(SUCCESS_GET_HAPPINESS_THEME.getMessage(), response)); + } + + @GetMapping + public ResponseEntity getHappinessRoutinesByThemes(@RequestParam(required = false) Long themeId) { + val response = happinessRoutineService.getHappinessRoutinesByTheme(themeId); + return ResponseEntity.ok(success(SUCCESS_GET_HAPPINESS_ROUTINE.getMessage(), response)); + } + + @GetMapping("/routine/{routineId}") + public ResponseEntity getHappinessSubRoutinesByRoutineOfTheme(@PathVariable long routineId) { + val response = happinessRoutineService.getHappinessSubRoutines(routineId); + return ResponseEntity.ok(success(SUCCESS_GET_HAPPINESS_SUB_ROUTINES.getMessage(), response)); + } +} diff --git a/src/main/java/com/soptie/server/routine/dto/DailyRoutineResponse.java b/src/main/java/com/soptie/server/routine/dto/DailyRoutineResponse.java new file mode 100644 index 00000000..c3d55a18 --- /dev/null +++ b/src/main/java/com/soptie/server/routine/dto/DailyRoutineResponse.java @@ -0,0 +1,21 @@ +package com.soptie.server.routine.dto; + +import com.soptie.server.routine.entity.daily.DailyRoutine; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.NonNull; + +@Builder(access = AccessLevel.PROTECTED) +public record DailyRoutineResponse( + Long routineId, + @NonNull String content +) { + + protected static DailyRoutineResponse of(DailyRoutine routine) { + return DailyRoutineResponse.builder() + .routineId(routine.getId()) + .content(routine.getContent()) + .build(); + } +} diff --git a/src/main/java/com/soptie/server/routine/dto/DailyRoutinesByThemeResponse.java b/src/main/java/com/soptie/server/routine/dto/DailyRoutinesByThemeResponse.java new file mode 100644 index 00000000..2245ac81 --- /dev/null +++ b/src/main/java/com/soptie/server/routine/dto/DailyRoutinesByThemeResponse.java @@ -0,0 +1,22 @@ +package com.soptie.server.routine.dto; + +import java.util.List; + +import com.soptie.server.routine.entity.daily.DailyRoutine; +import com.soptie.server.routine.entity.daily.DailyTheme; + +import lombok.Builder; +import lombok.NonNull; + +@Builder +public record DailyRoutinesByThemeResponse( + @NonNull String backgroundImageUrl, + @NonNull List routines +) { + + public static DailyRoutinesByThemeResponse of(DailyTheme theme, List routines) { + return new DailyRoutinesByThemeResponse( + theme.getImageInfo().getBackgroundImageUrl(), + routines.stream().map(DailyRoutineResponse::of).toList()); + } +} diff --git a/src/main/java/com/soptie/server/routine/dto/DailyRoutinesByThemesResponse.java b/src/main/java/com/soptie/server/routine/dto/DailyRoutinesByThemesResponse.java new file mode 100644 index 00000000..595b51f4 --- /dev/null +++ b/src/main/java/com/soptie/server/routine/dto/DailyRoutinesByThemesResponse.java @@ -0,0 +1,16 @@ +package com.soptie.server.routine.dto; + +import java.util.List; + +import com.soptie.server.routine.entity.daily.DailyRoutine; + +import lombok.NonNull; + +public record DailyRoutinesByThemesResponse( + @NonNull List routines +) { + + public static DailyRoutinesByThemesResponse of(List routines) { + return new DailyRoutinesByThemesResponse(routines.stream().map(DailyRoutineResponse::of).toList()); + } +} diff --git a/src/main/java/com/soptie/server/routine/dto/DailyThemesResponse.java b/src/main/java/com/soptie/server/routine/dto/DailyThemesResponse.java new file mode 100644 index 00000000..4dae3076 --- /dev/null +++ b/src/main/java/com/soptie/server/routine/dto/DailyThemesResponse.java @@ -0,0 +1,34 @@ +package com.soptie.server.routine.dto; + +import java.util.List; + +import com.soptie.server.routine.entity.daily.DailyTheme; + +import lombok.Builder; +import lombok.NonNull; + +public record DailyThemesResponse( + @NonNull List themes +) { + + public static DailyThemesResponse of(List themes) { + return new DailyThemesResponse(themes.stream().map(DailyThemeResponse::of).toList()); + } + + @Builder + public record DailyThemeResponse( + long themeId, + @NonNull String name, + @NonNull String iconImageUrl, + @NonNull String backgroundImageUrl + ) { + private static DailyThemeResponse of(DailyTheme theme) { + return DailyThemeResponse.builder() + .themeId(theme.getId()) + .name(theme.getName()) + .iconImageUrl(theme.getImageInfo().getIconImageUrl()) + .backgroundImageUrl(theme.getImageInfo().getBackgroundImageUrl()) + .build(); + } + } +} diff --git a/src/main/java/com/soptie/server/routine/dto/HappinessRoutinesResponse.java b/src/main/java/com/soptie/server/routine/dto/HappinessRoutinesResponse.java new file mode 100644 index 00000000..db24ea30 --- /dev/null +++ b/src/main/java/com/soptie/server/routine/dto/HappinessRoutinesResponse.java @@ -0,0 +1,36 @@ +package com.soptie.server.routine.dto; + +import com.soptie.server.routine.entity.happiness.HappinessRoutine; +import lombok.Builder; +import lombok.NonNull; + +import java.util.List; + +public record HappinessRoutinesResponse ( + @NonNull List routines +) { + + public static HappinessRoutinesResponse of(List routines) { + return new HappinessRoutinesResponse(routines.stream().map(HappinessRoutineResponse::of).toList()); + } + + @Builder + public record HappinessRoutineResponse( + long routineId, + @NonNull String name, + @NonNull String nameColor, + @NonNull String title, + @NonNull String iconImageUrl + ) { + + private static HappinessRoutineResponse of(HappinessRoutine routine) { + return HappinessRoutineResponse.builder() + .routineId(routine.getId()) + .name(routine.getTheme().getName()) + .nameColor(routine.getTheme().getNameColor()) + .title(routine.getTitle()) + .iconImageUrl(routine.getTheme().getImageInfo().getIconImageUrl()) + .build(); + } + } +} diff --git a/src/main/java/com/soptie/server/routine/dto/HappinessSubRoutinesResponse.java b/src/main/java/com/soptie/server/routine/dto/HappinessSubRoutinesResponse.java new file mode 100644 index 00000000..0f0e3c5d --- /dev/null +++ b/src/main/java/com/soptie/server/routine/dto/HappinessSubRoutinesResponse.java @@ -0,0 +1,50 @@ +package com.soptie.server.routine.dto; + +import com.soptie.server.routine.entity.happiness.HappinessRoutine; +import com.soptie.server.routine.entity.happiness.HappinessSubRoutine; +import lombok.Builder; +import lombok.NonNull; + +import java.util.List; + +@Builder +public record HappinessSubRoutinesResponse( + @NonNull String title, + @NonNull String name, + @NonNull String nameColor, + @NonNull String iconImageUrl, + @NonNull String contentImageUrl, + @NonNull List subRoutines +) { + + public static HappinessSubRoutinesResponse of(HappinessRoutine routine, List subRoutines) { + return HappinessSubRoutinesResponse.builder() + .title(routine.getTitle()) + .name(routine.getTheme().getName()) + .nameColor(routine.getTheme().getNameColor()) + .iconImageUrl(routine.getTheme().getImageInfo().getTwinkleIconImageUrl()) + .contentImageUrl(routine.getTheme().getImageInfo().getContentImageUrl()) + .subRoutines(subRoutines.stream().map(HappinessSubRoutineResponse::of).toList()) + .build(); + } + + @Builder + public record HappinessSubRoutineResponse( + long subRoutineId, + @NonNull String content, + @NonNull String detailContent, + @NonNull String timeTaken, + @NonNull String place + ) { + + private static HappinessSubRoutinesResponse.HappinessSubRoutineResponse of(HappinessSubRoutine subRoutine) { + return HappinessSubRoutineResponse.builder() + .subRoutineId(subRoutine.getId()) + .content(subRoutine.getContent()) + .detailContent(subRoutine.getDetailContent()) + .timeTaken(subRoutine.getTimeTaken()) + .place(subRoutine.getPlace()) + .build(); + } + } +} diff --git a/src/main/java/com/soptie/server/routine/dto/HappinessThemesResponse.java b/src/main/java/com/soptie/server/routine/dto/HappinessThemesResponse.java new file mode 100644 index 00000000..4c8a0fd3 --- /dev/null +++ b/src/main/java/com/soptie/server/routine/dto/HappinessThemesResponse.java @@ -0,0 +1,28 @@ +package com.soptie.server.routine.dto; + +import com.soptie.server.routine.entity.happiness.HappinessTheme; +import lombok.Builder; +import lombok.NonNull; + +import java.util.List; + +public record HappinessThemesResponse( + @NonNull List themes +) { + public static HappinessThemesResponse of(List themes) { + return new HappinessThemesResponse(themes.stream().map(HappinessThemeResponse::of).toList()); + } + + @Builder + public record HappinessThemeResponse( + long themeId, + @NonNull String name + ) { + private static HappinessThemeResponse of(HappinessTheme theme) { + return HappinessThemeResponse.builder() + .themeId(theme.getId()) + .name(theme.getName()) + .build(); + } + } +} diff --git a/src/main/java/com/soptie/server/routine/entity/daily/DailyRoutine.java b/src/main/java/com/soptie/server/routine/entity/daily/DailyRoutine.java new file mode 100644 index 00000000..0af75b17 --- /dev/null +++ b/src/main/java/com/soptie/server/routine/entity/daily/DailyRoutine.java @@ -0,0 +1,28 @@ +package com.soptie.server.routine.entity.daily; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@NoArgsConstructor +@Getter +public class DailyRoutine { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "routine_id") + private Long id; + + @Column(nullable = false) + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "theme_id") + private DailyTheme theme; + + @Builder + public DailyRoutine(String content, DailyTheme theme) { + this.content = content; + this.theme = theme; + } +} diff --git a/src/main/java/com/soptie/server/routine/entity/daily/DailyTheme.java b/src/main/java/com/soptie/server/routine/entity/daily/DailyTheme.java new file mode 100644 index 00000000..ab6fea5c --- /dev/null +++ b/src/main/java/com/soptie/server/routine/entity/daily/DailyTheme.java @@ -0,0 +1,27 @@ +package com.soptie.server.routine.entity.daily; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@NoArgsConstructor +@Getter +public class DailyTheme { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "theme_id") + private Long id; + + @Column(nullable = false) + private String name; + + @Embedded + private RoutineImage imageInfo; + + @Builder + public DailyTheme(String name, RoutineImage imageInfo) { + this.name = name; + this.imageInfo = imageInfo; + } +} diff --git a/src/main/java/com/soptie/server/routine/entity/daily/RoutineImage.java b/src/main/java/com/soptie/server/routine/entity/daily/RoutineImage.java new file mode 100644 index 00000000..36c7ae36 --- /dev/null +++ b/src/main/java/com/soptie/server/routine/entity/daily/RoutineImage.java @@ -0,0 +1,19 @@ +package com.soptie.server.routine.entity.daily; + +import jakarta.persistence.Embeddable; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Embeddable +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Getter +public class RoutineImage { + + private String iconImageUrl; + + private String backgroundImageUrl; +} diff --git a/src/main/java/com/soptie/server/routine/entity/happiness/HappinessRoutine.java b/src/main/java/com/soptie/server/routine/entity/happiness/HappinessRoutine.java new file mode 100644 index 00000000..20e5c6b3 --- /dev/null +++ b/src/main/java/com/soptie/server/routine/entity/happiness/HappinessRoutine.java @@ -0,0 +1,23 @@ +package com.soptie.server.routine.entity.happiness; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor +@Getter +public class HappinessRoutine { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "routine_id") + private Long id; + + @Column(nullable = false) + private String title; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "theme_id") + private HappinessTheme theme; +} diff --git a/src/main/java/com/soptie/server/routine/entity/happiness/HappinessSubRoutine.java b/src/main/java/com/soptie/server/routine/entity/happiness/HappinessSubRoutine.java new file mode 100644 index 00000000..1744fb7e --- /dev/null +++ b/src/main/java/com/soptie/server/routine/entity/happiness/HappinessSubRoutine.java @@ -0,0 +1,30 @@ +package com.soptie.server.routine.entity.happiness; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor +@Getter +public class HappinessSubRoutine { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "sub_routine_id") + private Long id; + + @Column(nullable = false) + private String content; + + @Column(columnDefinition = "TEXT") + private String detailContent; + + private String timeTaken; + + private String place; + + @ManyToOne + @JoinColumn(name = "routine_id") + private HappinessRoutine routine; +} diff --git a/src/main/java/com/soptie/server/routine/entity/happiness/HappinessTheme.java b/src/main/java/com/soptie/server/routine/entity/happiness/HappinessTheme.java new file mode 100644 index 00000000..56b9530c --- /dev/null +++ b/src/main/java/com/soptie/server/routine/entity/happiness/HappinessTheme.java @@ -0,0 +1,30 @@ +package com.soptie.server.routine.entity.happiness; + +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor +@Getter +public class HappinessTheme { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "theme_id") + private Long id; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String nameColor; + + @Embedded + private RoutineImage imageInfo; +} diff --git a/src/main/java/com/soptie/server/routine/entity/happiness/RoutineImage.java b/src/main/java/com/soptie/server/routine/entity/happiness/RoutineImage.java new file mode 100644 index 00000000..cbb821b2 --- /dev/null +++ b/src/main/java/com/soptie/server/routine/entity/happiness/RoutineImage.java @@ -0,0 +1,17 @@ +package com.soptie.server.routine.entity.happiness; + +import jakarta.persistence.Embeddable; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Embeddable +@NoArgsConstructor +@Getter +public class RoutineImage { + + private String iconImageUrl; + + private String contentImageUrl; + + private String twinkleIconImageUrl; +} diff --git a/src/main/java/com/soptie/server/routine/exception/RoutineException.java b/src/main/java/com/soptie/server/routine/exception/RoutineException.java new file mode 100644 index 00000000..09ba5c35 --- /dev/null +++ b/src/main/java/com/soptie/server/routine/exception/RoutineException.java @@ -0,0 +1,16 @@ +package com.soptie.server.routine.exception; + +import com.soptie.server.routine.message.ErrorCode; + +import lombok.Getter; + +@Getter +public class RoutineException extends RuntimeException { + + private final ErrorCode errorCode; + + public RoutineException(ErrorCode errorCode) { + super("[RoutineException] : " + errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/soptie/server/routine/message/ErrorCode.java b/src/main/java/com/soptie/server/routine/message/ErrorCode.java new file mode 100644 index 00000000..be52cb40 --- /dev/null +++ b/src/main/java/com/soptie/server/routine/message/ErrorCode.java @@ -0,0 +1,27 @@ +package com.soptie.server.routine.message; + +import static org.springframework.http.HttpStatus.*; + +import org.springframework.http.HttpStatus; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum ErrorCode { + + /* 400 BAD_REQUEST : 잘못된 요청 */ + CANNOT_ADD_MEMBER_ROUTINE(BAD_REQUEST, "더 이상 루틴을 추가할 수 없는 회원입니다."), + + /* 404 NOT_FOUND : 자원을 찾을 수 없음 */ + INVALID_THEME(NOT_FOUND, "유효하지 않은 테마입니다."), + INVALID_ROUTINE(NOT_FOUND, "유효하지 않은 루틴입니다."), + + /* 409 CONFLICT : 중복된 데이터 존재 */ + DUPLICATED_ROUTINE(CONFLICT, "이미 추가한 루틴입니다."), + ; + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/soptie/server/routine/message/SuccessMessage.java b/src/main/java/com/soptie/server/routine/message/SuccessMessage.java new file mode 100644 index 00000000..1a316a66 --- /dev/null +++ b/src/main/java/com/soptie/server/routine/message/SuccessMessage.java @@ -0,0 +1,18 @@ +package com.soptie.server.routine.message; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public enum SuccessMessage { + + SUCCESS_GET_THEME("데일리 루틴 테마 조회 성공"), + SUCCESS_GET_ROUTINE("데일리 루틴 조회 성공"), + SUCCESS_GET_HAPPINESS_THEME("행복 루틴 테마 조회 성공"), + SUCCESS_GET_HAPPINESS_ROUTINE("행복 루틴 조회 성공"), + SUCCESS_GET_HAPPINESS_SUB_ROUTINES("행복 루틴 별 서브 루틴 리스트 조회 성공"), + ; + + private final String message; +} diff --git a/src/main/java/com/soptie/server/routine/repository/daily/routine/DailyRoutineCustomRepository.java b/src/main/java/com/soptie/server/routine/repository/daily/routine/DailyRoutineCustomRepository.java new file mode 100644 index 00000000..4f59938d --- /dev/null +++ b/src/main/java/com/soptie/server/routine/repository/daily/routine/DailyRoutineCustomRepository.java @@ -0,0 +1,11 @@ +package com.soptie.server.routine.repository.daily.routine; + +import java.util.List; + +import com.soptie.server.routine.entity.daily.DailyRoutine; +import com.soptie.server.routine.entity.daily.DailyTheme; + +public interface DailyRoutineCustomRepository { + List findAllByThemes(List themeIds); + List findAllByTheme(DailyTheme theme); +} diff --git a/src/main/java/com/soptie/server/routine/repository/daily/routine/DailyRoutineRepository.java b/src/main/java/com/soptie/server/routine/repository/daily/routine/DailyRoutineRepository.java new file mode 100644 index 00000000..66f34373 --- /dev/null +++ b/src/main/java/com/soptie/server/routine/repository/daily/routine/DailyRoutineRepository.java @@ -0,0 +1,8 @@ +package com.soptie.server.routine.repository.daily.routine; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.soptie.server.routine.entity.daily.DailyRoutine; + +public interface DailyRoutineRepository extends JpaRepository, DailyRoutineCustomRepository { +} diff --git a/src/main/java/com/soptie/server/routine/repository/daily/routine/DailyRoutineRepositoryImpl.java b/src/main/java/com/soptie/server/routine/repository/daily/routine/DailyRoutineRepositoryImpl.java new file mode 100644 index 00000000..961baa6a --- /dev/null +++ b/src/main/java/com/soptie/server/routine/repository/daily/routine/DailyRoutineRepositoryImpl.java @@ -0,0 +1,42 @@ +package com.soptie.server.routine.repository.daily.routine; + +import static com.soptie.server.routine.entity.daily.QDailyRoutine.*; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.soptie.server.routine.entity.daily.DailyRoutine; +import com.soptie.server.routine.entity.daily.DailyTheme; + +import lombok.RequiredArgsConstructor; +import lombok.val; + +@Repository +@RequiredArgsConstructor +public class DailyRoutineRepositoryImpl implements DailyRoutineCustomRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public List findAllByThemes(List themeIds) { + val contentInKRExpression = Expressions.stringTemplate("SUBSTR({0}, 1, 1)", dailyRoutine.content); + return queryFactory + .selectFrom(dailyRoutine) + .where(dailyRoutine.theme.id.in(themeIds)) + .orderBy(contentInKRExpression.asc()) + .fetch(); + } + + @Override + public List findAllByTheme(DailyTheme theme) { + val contentInKRExpression = Expressions.stringTemplate("SUBSTR({0}, 1, 1)", dailyRoutine.content); + return queryFactory + .selectFrom(dailyRoutine) + .where(dailyRoutine.theme.eq(theme)) + .orderBy(contentInKRExpression.asc()) + .fetch(); + } +} diff --git a/src/main/java/com/soptie/server/routine/repository/daily/theme/DailyThemeCustomRepository.java b/src/main/java/com/soptie/server/routine/repository/daily/theme/DailyThemeCustomRepository.java new file mode 100644 index 00000000..a26b1018 --- /dev/null +++ b/src/main/java/com/soptie/server/routine/repository/daily/theme/DailyThemeCustomRepository.java @@ -0,0 +1,9 @@ +package com.soptie.server.routine.repository.daily.theme; + +import java.util.List; + +import com.soptie.server.routine.entity.daily.DailyTheme; + +public interface DailyThemeCustomRepository { + List findAllOrderByNameAsc(); +} diff --git a/src/main/java/com/soptie/server/routine/repository/daily/theme/DailyThemeRepository.java b/src/main/java/com/soptie/server/routine/repository/daily/theme/DailyThemeRepository.java new file mode 100644 index 00000000..6fde0ace --- /dev/null +++ b/src/main/java/com/soptie/server/routine/repository/daily/theme/DailyThemeRepository.java @@ -0,0 +1,8 @@ +package com.soptie.server.routine.repository.daily.theme; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.soptie.server.routine.entity.daily.DailyTheme; + +public interface DailyThemeRepository extends JpaRepository, DailyThemeCustomRepository { +} diff --git a/src/main/java/com/soptie/server/routine/repository/daily/theme/DailyThemeRepositoryImpl.java b/src/main/java/com/soptie/server/routine/repository/daily/theme/DailyThemeRepositoryImpl.java new file mode 100644 index 00000000..8478446b --- /dev/null +++ b/src/main/java/com/soptie/server/routine/repository/daily/theme/DailyThemeRepositoryImpl.java @@ -0,0 +1,31 @@ +package com.soptie.server.routine.repository.daily.theme; + +import static com.soptie.server.routine.entity.daily.QDailyRoutine.*; +import static com.soptie.server.routine.entity.daily.QDailyTheme.*; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.soptie.server.routine.entity.daily.DailyTheme; + +import lombok.RequiredArgsConstructor; +import lombok.val; + +@Repository +@RequiredArgsConstructor +public class DailyThemeRepositoryImpl implements DailyThemeCustomRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public List findAllOrderByNameAsc() { + val nameInKRExpression = Expressions.stringTemplate("SUBSTR({0}, 1, 1)", dailyTheme.name); + return queryFactory + .selectFrom(dailyTheme) + .orderBy(nameInKRExpression.asc()) + .fetch(); + } +} diff --git a/src/main/java/com/soptie/server/routine/repository/happiness/routine/HappinessRoutineCustomRepository.java b/src/main/java/com/soptie/server/routine/repository/happiness/routine/HappinessRoutineCustomRepository.java new file mode 100644 index 00000000..ed7d7c75 --- /dev/null +++ b/src/main/java/com/soptie/server/routine/repository/happiness/routine/HappinessRoutineCustomRepository.java @@ -0,0 +1,9 @@ +package com.soptie.server.routine.repository.happiness.routine; + +import com.soptie.server.routine.entity.happiness.HappinessRoutine; + +import java.util.List; + +public interface HappinessRoutineCustomRepository { + List findAllByThemeId(Long themeId); +} diff --git a/src/main/java/com/soptie/server/routine/repository/happiness/routine/HappinessRoutineRepository.java b/src/main/java/com/soptie/server/routine/repository/happiness/routine/HappinessRoutineRepository.java new file mode 100644 index 00000000..117b5c2a --- /dev/null +++ b/src/main/java/com/soptie/server/routine/repository/happiness/routine/HappinessRoutineRepository.java @@ -0,0 +1,7 @@ +package com.soptie.server.routine.repository.happiness.routine; + +import com.soptie.server.routine.entity.happiness.HappinessRoutine; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface HappinessRoutineRepository extends JpaRepository, HappinessRoutineCustomRepository { +} diff --git a/src/main/java/com/soptie/server/routine/repository/happiness/routine/HappinessRoutineRepositoryImpl.java b/src/main/java/com/soptie/server/routine/repository/happiness/routine/HappinessRoutineRepositoryImpl.java new file mode 100644 index 00000000..6eaee910 --- /dev/null +++ b/src/main/java/com/soptie/server/routine/repository/happiness/routine/HappinessRoutineRepositoryImpl.java @@ -0,0 +1,38 @@ +package com.soptie.server.routine.repository.happiness.routine; + + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.soptie.server.routine.entity.happiness.HappinessRoutine; +import lombok.RequiredArgsConstructor; +import lombok.val; +import org.springframework.stereotype.Repository; + +import java.util.List; + +import static com.soptie.server.routine.entity.happiness.QHappinessRoutine.happinessRoutine; +import static com.soptie.server.routine.entity.happiness.QHappinessTheme.happinessTheme; +import static java.util.Objects.*; + +@Repository +@RequiredArgsConstructor +public class HappinessRoutineRepositoryImpl implements HappinessRoutineCustomRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public List findAllByThemeId(Long themeId) { + val titleInKRExpression = Expressions.stringTemplate("SUBSTR({0}, 1, 1)", happinessRoutine.title); + return queryFactory + .selectFrom(happinessRoutine) + .where(themeIdEq(themeId)) + .leftJoin(happinessRoutine.theme, happinessTheme).fetchJoin() + .orderBy(titleInKRExpression.asc()) + .fetch(); + } + + private BooleanExpression themeIdEq(Long themeId) { + return (isNull(themeId) || themeId == 0L) ? null : happinessRoutine.theme.id.eq(themeId); + } +} diff --git a/src/main/java/com/soptie/server/routine/repository/happiness/routine/HappinessSubRoutineRepository.java b/src/main/java/com/soptie/server/routine/repository/happiness/routine/HappinessSubRoutineRepository.java new file mode 100644 index 00000000..6dfbc0c3 --- /dev/null +++ b/src/main/java/com/soptie/server/routine/repository/happiness/routine/HappinessSubRoutineRepository.java @@ -0,0 +1,12 @@ +package com.soptie.server.routine.repository.happiness.routine; + +import com.soptie.server.routine.entity.happiness.HappinessRoutine; +import org.springframework.data.jpa.repository.JpaRepository; + +import com.soptie.server.routine.entity.happiness.HappinessSubRoutine; + +import java.util.List; + +public interface HappinessSubRoutineRepository extends JpaRepository { + List findAllByRoutine(HappinessRoutine routine); +} diff --git a/src/main/java/com/soptie/server/routine/repository/happiness/theme/HappinessThemeCustomRepository.java b/src/main/java/com/soptie/server/routine/repository/happiness/theme/HappinessThemeCustomRepository.java new file mode 100644 index 00000000..c25ef1dc --- /dev/null +++ b/src/main/java/com/soptie/server/routine/repository/happiness/theme/HappinessThemeCustomRepository.java @@ -0,0 +1,9 @@ +package com.soptie.server.routine.repository.happiness.theme; + +import com.soptie.server.routine.entity.happiness.HappinessTheme; + +import java.util.List; + +public interface HappinessThemeCustomRepository { + List findAllOrderByNameAsc(); +} diff --git a/src/main/java/com/soptie/server/routine/repository/happiness/theme/HappinessThemeRepository.java b/src/main/java/com/soptie/server/routine/repository/happiness/theme/HappinessThemeRepository.java new file mode 100644 index 00000000..3d17c1d9 --- /dev/null +++ b/src/main/java/com/soptie/server/routine/repository/happiness/theme/HappinessThemeRepository.java @@ -0,0 +1,8 @@ +package com.soptie.server.routine.repository.happiness.theme; + +import com.soptie.server.routine.entity.happiness.HappinessTheme; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface HappinessThemeRepository extends JpaRepository, HappinessThemeCustomRepository { +} diff --git a/src/main/java/com/soptie/server/routine/repository/happiness/theme/HappinessThemeRepositoryImpl.java b/src/main/java/com/soptie/server/routine/repository/happiness/theme/HappinessThemeRepositoryImpl.java new file mode 100644 index 00000000..04055486 --- /dev/null +++ b/src/main/java/com/soptie/server/routine/repository/happiness/theme/HappinessThemeRepositoryImpl.java @@ -0,0 +1,26 @@ +package com.soptie.server.routine.repository.happiness.theme; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.soptie.server.routine.entity.happiness.HappinessTheme; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +import static com.soptie.server.routine.entity.happiness.QHappinessTheme.happinessTheme; + + +@Repository +@RequiredArgsConstructor +public class HappinessThemeRepositoryImpl implements HappinessThemeCustomRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public List findAllOrderByNameAsc() { + return queryFactory + .selectFrom(happinessTheme) + .orderBy(happinessTheme.name.asc()) + .fetch(); + } +} diff --git a/src/main/java/com/soptie/server/routine/service/DailyRoutineService.java b/src/main/java/com/soptie/server/routine/service/DailyRoutineService.java new file mode 100644 index 00000000..7402a0ce --- /dev/null +++ b/src/main/java/com/soptie/server/routine/service/DailyRoutineService.java @@ -0,0 +1,14 @@ +package com.soptie.server.routine.service; + +import java.util.List; + +import com.soptie.server.routine.dto.DailyRoutinesByThemeResponse; +import com.soptie.server.routine.dto.DailyRoutinesByThemesResponse; +import com.soptie.server.routine.dto.DailyThemesResponse; + +public interface DailyRoutineService { + + DailyThemesResponse getThemes(); + DailyRoutinesByThemesResponse getRoutinesByThemes(List themeIds); + DailyRoutinesByThemeResponse getRoutinesByTheme(long memberId, long themeId); +} diff --git a/src/main/java/com/soptie/server/routine/service/DailyRoutineServiceImpl.java b/src/main/java/com/soptie/server/routine/service/DailyRoutineServiceImpl.java new file mode 100644 index 00000000..1ef489a0 --- /dev/null +++ b/src/main/java/com/soptie/server/routine/service/DailyRoutineServiceImpl.java @@ -0,0 +1,69 @@ +package com.soptie.server.routine.service; + +import static com.soptie.server.member.message.ErrorCode.*; +import static com.soptie.server.routine.message.ErrorCode.*; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.soptie.server.member.entity.Member; +import com.soptie.server.member.exception.MemberException; +import com.soptie.server.member.repository.MemberRepository; +import com.soptie.server.routine.dto.DailyRoutinesByThemeResponse; +import com.soptie.server.routine.dto.DailyRoutinesByThemesResponse; +import com.soptie.server.routine.dto.DailyThemesResponse; +import com.soptie.server.routine.entity.daily.DailyRoutine; +import com.soptie.server.routine.entity.daily.DailyTheme; +import com.soptie.server.routine.exception.RoutineException; +import com.soptie.server.routine.repository.daily.routine.DailyRoutineRepository; +import com.soptie.server.routine.repository.daily.theme.DailyThemeRepository; + +import lombok.*; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class DailyRoutineServiceImpl implements DailyRoutineService { + + private final DailyThemeRepository dailyThemeRepository; + private final DailyRoutineRepository dailyRoutineRepository; + private final MemberRepository memberRepository; + + @Override + public DailyThemesResponse getThemes() { + val themes = dailyThemeRepository.findAllOrderByNameAsc(); + return DailyThemesResponse.of(themes); + } + + @Override + public DailyRoutinesByThemesResponse getRoutinesByThemes(List themeIds) { + val routines = dailyRoutineRepository.findAllByThemes(themeIds); + return DailyRoutinesByThemesResponse.of(routines); + } + + @Override + public DailyRoutinesByThemeResponse getRoutinesByTheme(long memberId, long themeId) { + val member = findMember(memberId); + val theme = findTheme(themeId); + val routines = getRoutines(member, theme); + return DailyRoutinesByThemeResponse.of(theme, routines); + } + + private Member findMember(long id) { + return memberRepository.findById(id) + .orElseThrow(() -> new MemberException(INVALID_MEMBER)); + } + + private DailyTheme findTheme(long id) { + return dailyThemeRepository.findById(id) + .orElseThrow(() -> new RoutineException(INVALID_THEME)); + } + + private List getRoutines(Member member, DailyTheme theme) { + return dailyRoutineRepository.findAllByTheme(theme).stream() + .filter(member::isNotExistRoutine) + .toList(); + } +} diff --git a/src/main/java/com/soptie/server/routine/service/HappinessRoutineService.java b/src/main/java/com/soptie/server/routine/service/HappinessRoutineService.java new file mode 100644 index 00000000..e1ad67a8 --- /dev/null +++ b/src/main/java/com/soptie/server/routine/service/HappinessRoutineService.java @@ -0,0 +1,11 @@ +package com.soptie.server.routine.service; + +import com.soptie.server.routine.dto.HappinessRoutinesResponse; +import com.soptie.server.routine.dto.HappinessSubRoutinesResponse; +import com.soptie.server.routine.dto.HappinessThemesResponse; + +public interface HappinessRoutineService { + HappinessThemesResponse getHappinessThemes(); + HappinessRoutinesResponse getHappinessRoutinesByTheme(Long themeId); + HappinessSubRoutinesResponse getHappinessSubRoutines(long routineId); +} diff --git a/src/main/java/com/soptie/server/routine/service/HappinessRoutineServiceImpl.java b/src/main/java/com/soptie/server/routine/service/HappinessRoutineServiceImpl.java new file mode 100644 index 00000000..5588a4e8 --- /dev/null +++ b/src/main/java/com/soptie/server/routine/service/HappinessRoutineServiceImpl.java @@ -0,0 +1,49 @@ +package com.soptie.server.routine.service; + +import static com.soptie.server.routine.message.ErrorCode.*; + +import com.soptie.server.routine.dto.HappinessRoutinesResponse; +import com.soptie.server.routine.dto.HappinessSubRoutinesResponse; +import com.soptie.server.routine.dto.HappinessThemesResponse; +import com.soptie.server.routine.entity.happiness.HappinessRoutine; +import com.soptie.server.routine.exception.RoutineException; +import com.soptie.server.routine.repository.happiness.routine.HappinessRoutineRepository; +import com.soptie.server.routine.repository.happiness.theme.HappinessThemeRepository; +import lombok.RequiredArgsConstructor; +import lombok.val; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class HappinessRoutineServiceImpl implements HappinessRoutineService { + + private final HappinessRoutineRepository happinessRoutineRepository; + private final HappinessThemeRepository happinessThemeRepository; + private final HappinessSubRoutineService happinessSubRoutineService; + + @Override + public HappinessThemesResponse getHappinessThemes() { + val themes = happinessThemeRepository.findAllOrderByNameAsc(); + return HappinessThemesResponse.of(themes); + } + + @Override + public HappinessRoutinesResponse getHappinessRoutinesByTheme(Long themeId) { + val routines = happinessRoutineRepository.findAllByThemeId(themeId); + return HappinessRoutinesResponse.of(routines); + } + + @Override + public HappinessSubRoutinesResponse getHappinessSubRoutines(long routineId) { + val routine = findRoutine(routineId); + val happinessSubRoutines = happinessSubRoutineService.getHappinessSubRoutines(routine); + return HappinessSubRoutinesResponse.of(routine, happinessSubRoutines); + } + + private HappinessRoutine findRoutine(long id) { + return happinessRoutineRepository.findById(id) + .orElseThrow(() -> new RoutineException(INVALID_ROUTINE)); + } +} diff --git a/src/main/java/com/soptie/server/routine/service/HappinessSubRoutineService.java b/src/main/java/com/soptie/server/routine/service/HappinessSubRoutineService.java new file mode 100644 index 00000000..a094d638 --- /dev/null +++ b/src/main/java/com/soptie/server/routine/service/HappinessSubRoutineService.java @@ -0,0 +1,10 @@ +package com.soptie.server.routine.service; + +import com.soptie.server.routine.entity.happiness.HappinessRoutine; +import com.soptie.server.routine.entity.happiness.HappinessSubRoutine; + +import java.util.List; + +public interface HappinessSubRoutineService { + List getHappinessSubRoutines(HappinessRoutine routine); +} diff --git a/src/main/java/com/soptie/server/routine/service/HappinessSubRoutineServiceImpl.java b/src/main/java/com/soptie/server/routine/service/HappinessSubRoutineServiceImpl.java new file mode 100644 index 00000000..6f5bccbc --- /dev/null +++ b/src/main/java/com/soptie/server/routine/service/HappinessSubRoutineServiceImpl.java @@ -0,0 +1,23 @@ +package com.soptie.server.routine.service; + +import com.soptie.server.routine.entity.happiness.HappinessRoutine; +import com.soptie.server.routine.entity.happiness.HappinessSubRoutine; +import com.soptie.server.routine.repository.happiness.routine.HappinessSubRoutineRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class HappinessSubRoutineServiceImpl implements HappinessSubRoutineService { + + private final HappinessSubRoutineRepository happinessSubRoutineRepository; + + @Override + public List getHappinessSubRoutines(HappinessRoutine routine) { + return happinessSubRoutineRepository.findAllByRoutine(routine); + } +} diff --git a/src/main/java/com/soptie/server/test/TestController.java b/src/main/java/com/soptie/server/test/TestController.java new file mode 100644 index 00000000..5c5a53ef --- /dev/null +++ b/src/main/java/com/soptie/server/test/TestController.java @@ -0,0 +1,16 @@ +package com.soptie.server.test; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/test") +public class TestController { + + @GetMapping + public ResponseEntity test() { + return ResponseEntity.ok("Success to server connect."); + } +} diff --git a/src/main/java/com/soptie/server/version/controller/VersionController.java b/src/main/java/com/soptie/server/version/controller/VersionController.java new file mode 100644 index 00000000..be5c49f8 --- /dev/null +++ b/src/main/java/com/soptie/server/version/controller/VersionController.java @@ -0,0 +1,28 @@ +package com.soptie.server.version.controller; + +import static com.soptie.server.version.message.SuccessMessage.*; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.soptie.server.common.dto.Response; +import com.soptie.server.version.service.VersionService; + +import lombok.RequiredArgsConstructor; +import lombok.val; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/versions") +public class VersionController { + + private final VersionService versionService; + + @GetMapping("/client/app") + public ResponseEntity getClientAppVersion() { + val response = versionService.getClientAppVersion(); + return ResponseEntity.ok(Response.success(SUCCESS_GET_APP_VERSION.getMessage(), response)); + } +} diff --git a/src/main/java/com/soptie/server/version/dto/AppVersionResponse.java b/src/main/java/com/soptie/server/version/dto/AppVersionResponse.java new file mode 100644 index 00000000..2445e820 --- /dev/null +++ b/src/main/java/com/soptie/server/version/dto/AppVersionResponse.java @@ -0,0 +1,29 @@ +package com.soptie.server.version.dto; + +import lombok.AccessLevel; +import lombok.Builder; + +@Builder(access = AccessLevel.PRIVATE) +public record AppVersionResponse( + VersionResponse iosVersion, + VersionResponse androidVersion, + String notificationTitle, + String notificationContent +) { + + public static AppVersionResponse of(String iOSAppVersion, String iosForceUpdateVersion, String androidAppVersion, + String androidForceUpdateVersion, String notificationTitle, String notificationContent) { + return AppVersionResponse.builder() + .iosVersion(new VersionResponse(iOSAppVersion, iosForceUpdateVersion)) + .androidVersion(new VersionResponse(androidAppVersion, androidForceUpdateVersion)) + .notificationTitle(notificationTitle) + .notificationContent(notificationContent) + .build(); + } + + private record VersionResponse( + String appVersion, + String forceUpdateVersion + ) { + } +} diff --git a/src/main/java/com/soptie/server/version/message/SuccessMessage.java b/src/main/java/com/soptie/server/version/message/SuccessMessage.java new file mode 100644 index 00000000..303516c4 --- /dev/null +++ b/src/main/java/com/soptie/server/version/message/SuccessMessage.java @@ -0,0 +1,14 @@ +package com.soptie.server.version.message; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public enum SuccessMessage { + + SUCCESS_GET_APP_VERSION("버전 정보 조회 성공"), + ; + + private final String message; +} diff --git a/src/main/java/com/soptie/server/version/service/VersionService.java b/src/main/java/com/soptie/server/version/service/VersionService.java new file mode 100644 index 00000000..c35f5e20 --- /dev/null +++ b/src/main/java/com/soptie/server/version/service/VersionService.java @@ -0,0 +1,7 @@ +package com.soptie.server.version.service; + +import com.soptie.server.version.dto.AppVersionResponse; + +public interface VersionService { + AppVersionResponse getClientAppVersion(); +} diff --git a/src/main/java/com/soptie/server/version/service/VersionServiceImpl.java b/src/main/java/com/soptie/server/version/service/VersionServiceImpl.java new file mode 100644 index 00000000..42c24f16 --- /dev/null +++ b/src/main/java/com/soptie/server/version/service/VersionServiceImpl.java @@ -0,0 +1,28 @@ +package com.soptie.server.version.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.soptie.server.common.config.ValueConfig; +import com.soptie.server.version.dto.AppVersionResponse; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class VersionServiceImpl implements VersionService { + + private final ValueConfig valueConfig; + + @Override + public AppVersionResponse getClientAppVersion() { + return AppVersionResponse.of( + valueConfig.getIOS_APP_VERSION(), + valueConfig.getIOS_FORCE_UPDATE_VERSION(), + valueConfig.getANDROID_APP_VERSION(), + valueConfig.getANDROID_FORCE_UPDATE_VERSION(), + valueConfig.getNOTIFICATION_TITLE(), + valueConfig.getNOTIFICATION_CONTENT()); + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 00000000..53e3b204 --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,62 @@ +spring: + config: + import: application-secret.yml + activate: + on-profile: dev + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://${DATABASE.ENDPOINT_URL.dev}:5432/postgres?currentSchema=${DATABASE.NAME.dev} + username: ${DATABASE.USERNAME.dev} + password: ${DATABASE.PASSWORD.dev} + jpa: + hibernate: + ddl-auto: none + properties: + hibernate: + format_sql: true + default_batch_fetch_size: 1000 + auto_quote_keyword: true + security: + oauth2: + client: + registration: + kakao: + client-id: ${KAKAO.CLIENT_ID} + redirect-uri: ${KAKAO.REDIRECT_URI} + authorization-grant-type: authorization_code + scope: profile_nickname + client-name: Kakao + client-secret: ${KAKAO.CLIENT_SECRET} + client-authentication-method: POST + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id + +logging: + level: + org.hibernate.SQL: debug + slack: + webhook_url: ${SLACK.WEBHOOK_URL.dev} + config: classpath:logback-spring.xml + +jwt: + secret: ${JWT.SECRET} + KAKAO_URL: https://kapi.kakao.com/v2/user/me + APPLE_URL: https://appleid.apple.com/auth/keys + ACCESS_TOKEN_EXPIRED: 7200000 + REFRESH_TOKEN_EXPIRED: 1209600000 + +springdoc: + default-consumes-media-type: application/json;charset=UTF-8 + default-produces-media-type: application/json;charset=UTF-8 + swagger-ui: + url: /docs/open-api-3.0.1.json + path: /swagger + +softie: + cron: + init: + routine: "0 0 0 * * *" \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 00000000..60ec89ec --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,55 @@ +spring: + config: + import: application-secret.yml + activate: + on-profile: prod + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://${DATABASE.ENDPOINT_URL.prod}:5432/postgres?currentSchema=${DATABASE.NAME.prod} + username: ${DATABASE.USERNAME.prod} + password: ${DATABASE.PASSWORD.prod} + jpa: + hibernate: + ddl-auto: none + properties: + hibernate: + format_sql: true + default_batch_fetch_size: 1000 + auto_quote_keyword: true + security: + oauth2: + client: + registration: + kakao: + client-id: ${KAKAO.CLIENT_ID} + redirect-uri: ${KAKAO.REDIRECT_URI} + authorization-grant-type: authorization_code + scope: profile_nickname + client-name: Kakao + client-secret: ${KAKAO.CLIENT_SECRET} + client-authentication-method: POST + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id + +logging: + level: + org.hibernate.SQL: debug + slack: + webhook_url: ${SLACK.WEBHOOK_URL.prod} + config: classpath:logback-spring.xml + +jwt: + secret: ${JWT.SECRET} + KAKAO_URL: https://kapi.kakao.com/v2/user/me + APPLE_URL: https://appleid.apple.com/auth/keys + ACCESS_TOKEN_EXPIRED: 7200000 + REFRESH_TOKEN_EXPIRED: 1209600000 + +softie: + cron: + init: + routine: "0 0 0 * * *" \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 8b137891..00000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 00000000..305b57ec --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,31 @@ + + + + + ${SLACK_WEBHOOK_URI} + + %d{yyyy-MM-dd HH:mm:ss.SSS} %msg %n + + Cake-Server-log + :stuck_out_tongue_winking_eye: + true + + + + + %d %-5level %logger{35} - %msg%n + + + + + + + ERROR + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/static/docs/open-api-3.0.1.json b/src/main/resources/static/docs/open-api-3.0.1.json new file mode 100644 index 00000000..4bae6b80 --- /dev/null +++ b/src/main/resources/static/docs/open-api-3.0.1.json @@ -0,0 +1,1499 @@ +{ + "openapi" : "3.0.1", + "info" : { + "title" : "소프티 API", + "description" : "소프티 API 명세서", + "version" : "0.0.1" + }, + "servers" : [ { + "url" : "http://localhost:8080" + } ], + "tags" : [ ], + "paths" : { + "/api/v1/auth" : { + "post" : { + "tags" : [ "AUTH" ], + "summary" : "소셜 로그인", + "description" : "소셜 로그인", + "operationId" : "post-token-docs", + "parameters" : [ { + "name" : "Authorization", + "in" : "header", + "description" : "Social Access Token", + "required" : true, + "schema" : { + "type" : "string" + }, + "example" : "Bearer softietoken" + } ], + "requestBody" : { + "content" : { + "application/json;charset=UTF-8" : { + "schema" : { + "$ref" : "#/components/schemas/api-v1-auth-1040425838" + }, + "examples" : { + "post-token-docs" : { + "value" : "{\n \"socialType\" : \"KAKAO\"\n}" + } + } + } + } + }, + "responses" : { + "200" : { + "description" : "200", + "content" : { + "application/json;charset=UTF-8" : { + "schema" : { + "$ref" : "#/components/schemas/api-v1-auth123505361" + }, + "examples" : { + "post-token-docs" : { + "value" : "{\n \"success\" : true,\n \"message\" : \"소셜로그인 성공\",\n \"data\" : {\n \"accessToken\" : \"softie\",\n \"refreshToken\" : \"token\",\n \"isMemberDollExist\" : false\n }\n}" + } + } + } + } + } + } + }, + "delete" : { + "tags" : [ "AUTH" ], + "summary" : "회원탈퇴", + "description" : "회원탈퇴", + "operationId" : "delete-withdrawal-docs", + "responses" : { + "200" : { + "description" : "200", + "content" : { + "application/json;charset=UTF-8" : { + "schema" : { + "$ref" : "#/components/schemas/api-v1-auth594740350" + }, + "examples" : { + "delete-withdrawal-docs" : { + "value" : "{\n \"success\" : true,\n \"message\" : \"회원 탈퇴 성공\",\n \"data\" : null\n}" + } + } + } + } + } + } + } + }, + "/api/v1/members" : { + "get" : { + "tags" : [ "MEMBER" ], + "summary" : "홈 화면 불러오기", + "description" : "홈 화면 불러오기", + "operationId" : "get-member-home-screen-docs", + "responses" : { + "200" : { + "description" : "200", + "content" : { + "application/json;charset=UTF-8" : { + "schema" : { + "$ref" : "#/components/schemas/api-v1-members-980081866" + }, + "examples" : { + "get-member-home-screen-docs" : { + "value" : "{\n \"success\" : true,\n \"message\" : \"홈 화면 불러오기 성공\",\n \"data\" : {\n \"name\" : \"softie\",\n \"dollType\" : \"BROWN\",\n \"frameImageUrl\" : \"https://...\",\n \"dailyCottonCount\" : 0,\n \"happinessCottonCount\" : 0,\n \"conversations\" : [ \"안녕\", \"하이\", \"봉쥬르\" ]\n }\n}" + } + } + } + } + } + } + }, + "post" : { + "tags" : [ "MEMBER" ], + "summary" : "프로필 생성", + "description" : "프로필 생성", + "operationId" : "post-member-profile-docs", + "requestBody" : { + "content" : { + "application/json;charset=UTF-8" : { + "schema" : { + "$ref" : "#/components/schemas/api-v1-members-2061462562" + }, + "examples" : { + "post-member-profile-docs" : { + "value" : "{\n \"dollType\" : \"BROWN\",\n \"name\" : \"소프티\",\n \"routines\" : [ 1, 2, 3 ]\n}" + } + } + } + } + }, + "responses" : { + "201" : { + "description" : "201", + "headers" : { + "Location" : { + "description" : "Redirect URI", + "schema" : { + "type" : "string" + } + } + }, + "content" : { + "application/json;charset=UTF-8" : { + "schema" : { + "$ref" : "#/components/schemas/api-v1-auth594740350" + }, + "examples" : { + "post-member-profile-docs" : { + "value" : "{\n \"success\" : true,\n \"message\" : \"프로필 생성 성공\",\n \"data\" : null\n}" + } + } + } + } + } + } + } + }, + "/api/v1/test" : { + "get" : { + "tags" : [ "TEST" ], + "summary" : "서버 연결 테스트", + "description" : "서버 연결 테스트", + "operationId" : "test-docs", + "responses" : { + "200" : { + "description" : "200", + "content" : { + "application/json;charset=UTF-8" : { + "schema" : { + "$ref" : "#/components/schemas/api-v1-test486549215" + }, + "examples" : { + "test-docs" : { + "value" : "Success to server connect." + } + } + } + } + } + } + } + }, + "/api/v1/auth/logout" : { + "post" : { + "tags" : [ "AUTH" ], + "summary" : "로그아웃", + "description" : "로그아웃", + "operationId" : "post-logout-docs", + "responses" : { + "200" : { + "description" : "200", + "content" : { + "application/json;charset=UTF-8" : { + "schema" : { + "$ref" : "#/components/schemas/api-v1-auth594740350" + }, + "examples" : { + "post-logout-docs" : { + "value" : "{\n \"success\" : true,\n \"message\" : \"로그아웃 성공\",\n \"data\" : null\n}" + } + } + } + } + } + } + } + }, + "/api/v1/auth/token" : { + "post" : { + "tags" : [ "AUTH" ], + "summary" : "토큰 재발급", + "description" : "토큰 재발급", + "operationId" : "post-recreate-token-docs", + "responses" : { + "200" : { + "description" : "200", + "content" : { + "application/json;charset=UTF-8" : { + "schema" : { + "$ref" : "#/components/schemas/api-v1-auth-token-831815960" + }, + "examples" : { + "post-recreate-token-docs" : { + "value" : "{\n \"success\" : true,\n \"message\" : \"토큰 재발급 성공\",\n \"data\" : {\n \"accessToken\" : \"accessToken\"\n }\n}" + } + } + } + } + } + } + } + }, + "/api/v1/routines/daily" : { + "get" : { + "tags" : [ "DAILY ROUTINE" ], + "summary" : "테마 리스트 별 데일리 루틴 리스트 조회", + "description" : "테마 리스트 별 데일리 루틴 리스트 조회", + "operationId" : "get-themes-daily-routines-docs", + "parameters" : [ { + "name" : "themes", + "in" : "query", + "description" : "조회할 테마 id 정보", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "description" : "200", + "content" : { + "application/json;charset=UTF-8" : { + "schema" : { + "$ref" : "#/components/schemas/api-v1-routines-daily-54531209" + }, + "examples" : { + "get-themes-daily-routines-docs" : { + "value" : "{\n \"success\" : true,\n \"message\" : \"루틴 조회 성공\",\n \"data\" : {\n \"routines\" : [ {\n \"routineId\" : 1,\n \"content\" : \"routine content1\"\n }, {\n \"routineId\" : 2,\n \"content\" : \"routine content2\"\n }, {\n \"routineId\" : 3,\n \"content\" : \"routine content3\"\n }, {\n \"routineId\" : 4,\n \"content\" : \"routine content4\"\n }, {\n \"routineId\" : 5,\n \"content\" : \"routine content5\"\n } ]\n }\n}" + } + } + } + } + } + } + } + }, + "/api/v1/routines/happiness" : { + "get" : { + "tags" : [ "HAPPINESS ROUTINE" ], + "summary" : "테마 별 행복 루틴 리스트 조회", + "description" : "테마 별 행복 루틴 리스트 조회", + "operationId" : "get-theme-happiness-routines-docs", + "parameters" : [ { + "name" : "themeId", + "in" : "query", + "description" : "조회할 테마 id", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "description" : "200", + "content" : { + "application/json;charset=UTF-8" : { + "schema" : { + "$ref" : "#/components/schemas/api-v1-routines-happiness1133934080" + }, + "examples" : { + "get-theme-happiness-routines-docs" : { + "value" : "{\n \"success\" : true,\n \"message\" : \"루틴 조회 성공\",\n \"data\" : {\n \"routines\" : [ {\n \"routineId\" : 1,\n \"name\" : \"Happiness routine Theme1\",\n \"nameColor\" : \"Happiness routine Theme1\",\n \"title\" : \"Happiness routine Theme1\",\n \"iconImageUrl\" : \"icon_image_url1\"\n }, {\n \"routineId\" : 2,\n \"name\" : \"Happiness routine Theme2\",\n \"nameColor\" : \"Happiness routine Theme2\",\n \"title\" : \"Happiness routine Theme2\",\n \"iconImageUrl\" : \"icon_image_url2\"\n }, {\n \"routineId\" : 3,\n \"name\" : \"Happiness routine Theme3\",\n \"nameColor\" : \"Happiness routine Theme3\",\n \"title\" : \"Happiness routine Theme3\",\n \"iconImageUrl\" : \"icon_image_url3\"\n }, {\n \"routineId\" : 4,\n \"name\" : \"Happiness routine Theme4\",\n \"nameColor\" : \"Happiness routine Theme4\",\n \"title\" : \"Happiness routine Theme4\",\n \"iconImageUrl\" : \"icon_image_url4\"\n }, {\n \"routineId\" : 5,\n \"name\" : \"Happiness routine Theme5\",\n \"nameColor\" : \"Happiness routine Theme5\",\n \"title\" : \"Happiness routine Theme5\",\n \"iconImageUrl\" : \"icon_image_url5\"\n } ]\n }\n}" + } + } + } + } + } + } + } + }, + "/api/v1/dolls/image/{type}" : { + "get" : { + "tags" : [ "DOLL" ], + "summary" : "인형 이미지 조회", + "description" : "인형 이미지 조회", + "operationId" : "get-doll-image-docs", + "parameters" : [ { + "name" : "type", + "in" : "path", + "description" : "인형 타입", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "description" : "200", + "content" : { + "application/json;charset=UTF-8" : { + "schema" : { + "$ref" : "#/components/schemas/api-v1-dolls-image-type-1785135499" + }, + "examples" : { + "get-doll-image-docs" : { + "value" : "{\n \"success\" : true,\n \"message\" : \"인형 이미지 조회 성공\",\n \"data\" : {\n \"faceImageUrl\" : \"face-image-url\"\n }\n}" + } + } + } + } + } + } + } + }, + "/api/v1/members/cotton/{cottonType}" : { + "patch" : { + "tags" : [ "MEMBER" ], + "summary" : "솜뭉치 주기", + "description" : "솜뭉치 주기", + "operationId" : "give-cotton-docs", + "parameters" : [ { + "name" : "cottonType", + "in" : "path", + "description" : "솜뭉치 종류", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "description" : "200", + "content" : { + "application/json;charset=UTF-8" : { + "schema" : { + "$ref" : "#/components/schemas/api-v1-members-cotton-cottonType-1561973557" + }, + "examples" : { + "give-cotton-docs" : { + "value" : "{\n \"success\" : true,\n \"message\" : \"솜뭉치 주기 성공\",\n \"data\" : {\n \"cottonCount\" : 0\n }\n}" + } + } + } + } + } + } + } + }, + "/api/v1/routines/daily/member" : { + "get" : { + "tags" : [ "MEMBER DAILY ROUTINE" ], + "summary" : "회원 별 데일리 루틴 리스트 조회", + "description" : "회원 별 데일리 루틴 리스트 조회", + "operationId" : "get-member-daily-routines-docs", + "responses" : { + "200" : { + "description" : "200", + "content" : { + "application/json;charset=UTF-8" : { + "schema" : { + "$ref" : "#/components/schemas/api-v1-routines-daily-member-336344243" + }, + "examples" : { + "get-member-daily-routines-docs" : { + "value" : "{\n \"success\" : true,\n \"message\" : \"루틴 조회 성공\",\n \"data\" : {\n \"routines\" : [ {\n \"routineId\" : 1,\n \"content\" : \"routine content1\",\n \"iconImageUrl\" : \"https://...\",\n \"achieveCount\" : 1,\n \"isAchieve\" : false\n }, {\n \"routineId\" : 2,\n \"content\" : \"routine content2\",\n \"iconImageUrl\" : \"https://...\",\n \"achieveCount\" : 2,\n \"isAchieve\" : true\n }, {\n \"routineId\" : 3,\n \"content\" : \"routine content3\",\n \"iconImageUrl\" : \"https://...\",\n \"achieveCount\" : 3,\n \"isAchieve\" : false\n }, {\n \"routineId\" : 4,\n \"content\" : \"routine content4\",\n \"iconImageUrl\" : \"https://...\",\n \"achieveCount\" : 4,\n \"isAchieve\" : true\n }, {\n \"routineId\" : 5,\n \"content\" : \"routine content5\",\n \"iconImageUrl\" : \"https://...\",\n \"achieveCount\" : 5,\n \"isAchieve\" : false\n } ]\n }\n}" + } + } + } + } + } + } + }, + "post" : { + "tags" : [ "MEMBER DAILY ROUTINE" ], + "summary" : "회원 데일리 루틴 추가 성공", + "description" : "회원 데일리 루틴 추가 성공", + "operationId" : "post-member-daily-routine-docs", + "requestBody" : { + "content" : { + "application/json;charset=UTF-8" : { + "schema" : { + "$ref" : "#/components/schemas/api-v1-routines-daily-member1721287602" + }, + "examples" : { + "post-member-daily-routine-docs" : { + "value" : "{\n \"routineId\" : 1\n}" + } + } + } + } + }, + "responses" : { + "201" : { + "description" : "201", + "headers" : { + "Location" : { + "description" : "Redirect URI", + "schema" : { + "type" : "string" + } + } + }, + "content" : { + "application/json;charset=UTF-8" : { + "schema" : { + "$ref" : "#/components/schemas/api-v1-routines-daily-member1327124516" + }, + "examples" : { + "post-member-daily-routine-docs" : { + "value" : "{\n \"success\" : true,\n \"message\" : \"루틴 추가 성공\",\n \"data\" : {\n \"routineId\" : 1\n }\n}" + } + } + } + } + } + } + }, + "delete" : { + "tags" : [ "MEMBER DAILY ROUTINE" ], + "summary" : "회원 데일리 루틴 삭제", + "description" : "회원 데일리 루틴 삭제", + "operationId" : "delete-member-daily-routines-docs", + "parameters" : [ { + "name" : "routines", + "in" : "query", + "description" : "삭제할 루틴 id 리스트", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "description" : "200", + "content" : { + "application/json;charset=UTF-8" : { + "schema" : { + "$ref" : "#/components/schemas/api-v1-auth594740350" + }, + "examples" : { + "delete-member-daily-routines-docs" : { + "value" : "{\n \"success\" : true,\n \"message\" : \"루틴 삭제 성공\",\n \"data\" : null\n}" + } + } + } + } + } + } + } + }, + "/api/v1/routines/daily/themes" : { + "get" : { + "tags" : [ "DAILY ROUTINE" ], + "summary" : "데일리 루틴 테마 리스트 조회", + "description" : "데일리 루틴 테마 리스트 조회", + "operationId" : "get-daily-themes-docs", + "responses" : { + "200" : { + "description" : "200", + "content" : { + "application/json;charset=UTF-8" : { + "schema" : { + "$ref" : "#/components/schemas/api-v1-routines-daily-themes1975236624" + }, + "examples" : { + "get-daily-themes-docs" : { + "value" : "{\n \"success\" : true,\n \"message\" : \"테마 조회 성공\",\n \"data\" : {\n \"themes\" : [ {\n \"themeId\" : 1,\n \"name\" : \"theme name1\",\n \"iconImageUrl\" : \"https://...\",\n \"backgroundImageUrl\" : \"https://...\"\n }, {\n \"themeId\" : 2,\n \"name\" : \"theme name2\",\n \"iconImageUrl\" : \"https://...\",\n \"backgroundImageUrl\" : \"https://...\"\n }, {\n \"themeId\" : 3,\n \"name\" : \"theme name3\",\n \"iconImageUrl\" : \"https://...\",\n \"backgroundImageUrl\" : \"https://...\"\n }, {\n \"themeId\" : 4,\n \"name\" : \"theme name4\",\n \"iconImageUrl\" : \"https://...\",\n \"backgroundImageUrl\" : \"https://...\"\n }, {\n \"themeId\" : 5,\n \"name\" : \"theme name5\",\n \"iconImageUrl\" : \"https://...\",\n \"backgroundImageUrl\" : \"https://...\"\n } ]\n }\n}" + } + } + } + } + } + } + } + }, + "/api/v1/routines/happiness/member" : { + "get" : { + "tags" : [ "MEMBER HAPPINESS ROUTINE" ], + "summary" : "회원 별 행복 루틴 리스트 조회", + "description" : "회원 별 행복 루틴 리스트 조회", + "operationId" : "get-member-happiness-routine-docs", + "responses" : { + "200" : { + "description" : "200", + "content" : { + "application/json;charset=UTF-8" : { + "schema" : { + "$ref" : "#/components/schemas/api-v1-routines-happiness-member-160843619" + }, + "examples" : { + "get-member-happiness-routine-docs" : { + "value" : "{\n \"success\" : true,\n \"message\" : \"루틴 조회 성공\",\n \"data\" : {\n \"routineId\" : 1,\n \"iconImageUrl\" : \"icon-image-url\",\n \"contentImageUrl\" : \"content-image-url\",\n \"themeName\" : \"태마 이름\",\n \"themeNameColor\" : \"#ffcdc5\",\n \"title\" : \"루틴 제목\",\n \"content\" : \"routine-content\",\n \"detailContent\" : \"routine-detail-content\",\n \"place\" : \"루틴 장소\",\n \"timeTaken\" : \"5~10분 소요\"\n }\n}" + } + } + } + } + } + } + }, + "post" : { + "tags" : [ "MEMBER HAPPINESS ROUTINE" ], + "summary" : "회원 행복 루틴 추가 성공", + "description" : "회원 행복 루틴 추가 성공", + "operationId" : "post-member-happiness-routine-docs", + "requestBody" : { + "content" : { + "application/json;charset=UTF-8" : { + "schema" : { + "$ref" : "#/components/schemas/api-v1-routines-happiness-member1502060882" + }, + "examples" : { + "post-member-happiness-routine-docs" : { + "value" : "{\n \"subRoutineId\" : 1\n}" + } + } + } + } + }, + "responses" : { + "201" : { + "description" : "201", + "headers" : { + "Location" : { + "description" : "Redirect URI", + "schema" : { + "type" : "string" + } + } + }, + "content" : { + "application/json;charset=UTF-8" : { + "schema" : { + "$ref" : "#/components/schemas/api-v1-routines-daily-member1327124516" + }, + "examples" : { + "post-member-happiness-routine-docs" : { + "value" : "{\n \"success\" : true,\n \"message\" : \"루틴 추가 성공\",\n \"data\" : {\n \"routineId\" : 1\n }\n}" + } + } + } + } + } + } + } + }, + "/api/v1/routines/happiness/themes" : { + "get" : { + "tags" : [ "HAPPINESS ROUTINE" ], + "summary" : "행복 루틴 테마 리스트 조회", + "description" : "행복 루틴 테마 리스트 조회", + "operationId" : "get-happiness-themes-docs", + "responses" : { + "200" : { + "description" : "200", + "content" : { + "application/json;charset=UTF-8" : { + "schema" : { + "$ref" : "#/components/schemas/api-v1-routines-happiness-themes460193954" + }, + "examples" : { + "get-happiness-themes-docs" : { + "value" : "{\n \"success\" : true,\n \"message\" : \"테마 조회 성공\",\n \"data\" : {\n \"themes\" : [ {\n \"themeId\" : 1,\n \"name\" : \"Happiness routine Theme1\"\n }, {\n \"themeId\" : 2,\n \"name\" : \"Happiness routine Theme2\"\n }, {\n \"themeId\" : 3,\n \"name\" : \"Happiness routine Theme3\"\n }, {\n \"themeId\" : 4,\n \"name\" : \"Happiness routine Theme4\"\n }, {\n \"themeId\" : 5,\n \"name\" : \"Happiness routine Theme5\"\n } ]\n }\n}" + } + } + } + } + } + } + } + }, + "/api/v1/versions/client/app" : { + "get" : { + "tags" : [ "VERSION" ], + "summary" : "앱 버전 정보 조회", + "description" : "앱 버전 정보 조회", + "operationId" : "get-client-app-version-docs", + "responses" : { + "200" : { + "description" : "200", + "content" : { + "application/json;charset=UTF-8" : { + "schema" : { + "$ref" : "#/components/schemas/api-v1-versions-client-app2103188655" + }, + "examples" : { + "get-client-app-version-docs" : { + "value" : "{\n \"success\" : true,\n \"message\" : \"버전 조회 성공\",\n \"data\" : {\n \"iosVersion\" : {\n \"appVersion\" : \"1.0.0\",\n \"forceUpdateVersion\" : \"1.0.0\"\n },\n \"androidVersion\" : {\n \"appVersion\" : \"1.0.0\",\n \"forceUpdateVersion\" : \"1.0.0\"\n },\n \"notificationTitle\" : \"업데이트 안내\",\n \"notificationContent\" : \"업데이트가 필요합니다.\"\n }\n}" + } + } + } + } + } + } + } + }, + "/api/v1/routines/daily/theme/{themeId}" : { + "get" : { + "tags" : [ "DAILY ROUTINE" ], + "summary" : "테마 별 데일리 루틴 리스트 조회", + "description" : "테마 별 데일리 루틴 리스트 조회", + "operationId" : "get-theme-daily-routines-docs", + "parameters" : [ { + "name" : "themeId", + "in" : "path", + "description" : "테마 id", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "description" : "200", + "content" : { + "application/json;charset=UTF-8" : { + "schema" : { + "$ref" : "#/components/schemas/api-v1-routines-daily-theme-themeId-1869963578" + }, + "examples" : { + "get-theme-daily-routines-docs" : { + "value" : "{\n \"success\" : true,\n \"message\" : \"루틴 조회 성공\",\n \"data\" : {\n \"backgroundImageUrl\" : \"https://...\",\n \"routines\" : [ {\n \"routineId\" : 1,\n \"content\" : \"routine content1\"\n }, {\n \"routineId\" : 2,\n \"content\" : \"routine content2\"\n }, {\n \"routineId\" : 3,\n \"content\" : \"routine content3\"\n }, {\n \"routineId\" : 4,\n \"content\" : \"routine content4\"\n }, {\n \"routineId\" : 5,\n \"content\" : \"routine content5\"\n } ]\n }\n}" + } + } + } + } + } + } + } + }, + "/api/v1/routines/happiness/routine/{routineId}" : { + "get" : { + "tags" : [ "HAPPINESS ROUTINE" ], + "summary" : "행복 루틴 별 서브 행복 루틴 리스트 조회", + "description" : "행복 루틴 별 서브 행복 루틴 리스트 조회", + "operationId" : "get-sub-happiness-routines-by-routine-of-theme-docs", + "parameters" : [ { + "name" : "routineId", + "in" : "path", + "description" : "루틴 id", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "description" : "200", + "content" : { + "application/json;charset=UTF-8" : { + "schema" : { + "$ref" : "#/components/schemas/api-v1-routines-happiness-routine-routineId2107530734" + }, + "examples" : { + "get-sub-happiness-routines-by-routine-of-theme-docs" : { + "value" : "{\n \"success\" : true,\n \"message\" : \"행복 루틴 별 서브 루틴 리스트 조회 성공\",\n \"data\" : {\n \"title\" : \"타이틀\",\n \"name\" : \"테마 이름\",\n \"nameColor\" : \"#999\",\n \"iconImageUrl\" : \"https://...\",\n \"contentImageUrl\" : \"https://...\",\n \"subRoutines\" : [ {\n \"subRoutineId\" : 1,\n \"content\" : \"소프티 숙소\",\n \"detailContent\" : \"어쩌구 저쩌구\",\n \"timeTaken\" : \"아무때\",\n \"place\" : \"소프티 만나러 가는 길\"\n } ]\n }\n}" + } + } + } + } + } + } + } + }, + "/api/v1/routines/daily/member/routine/{routineId}" : { + "patch" : { + "tags" : [ "MEMBER DAILY ROUTINE" ], + "summary" : "회원 데일리 루틴 달성 성공", + "description" : "회원 데일리 루틴 달성 성공", + "operationId" : "achieve-member-daily-routine-docs", + "parameters" : [ { + "name" : "routineId", + "in" : "path", + "description" : "루틴 id", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "description" : "200", + "content" : { + "application/json;charset=UTF-8" : { + "schema" : { + "$ref" : "#/components/schemas/api-v1-routines-daily-member-routine-routineId-2021368021" + }, + "examples" : { + "achieve-member-daily-routine-docs" : { + "value" : "{\n \"success\" : true,\n \"message\" : \"루틴 달성 성공\",\n \"data\" : {\n \"routineId\" : 1,\n \"isAchieve\" : true,\n \"achieveCount\" : 1\n }\n}" + } + } + } + } + } + } + } + }, + "/api/v1/routines/happiness/member/routine/{routineId}" : { + "delete" : { + "tags" : [ "MEMBER HAPPINESS ROUTINE" ], + "summary" : "회원 행복 루틴 삭제 성공", + "description" : "회원 행복 루틴 삭제 성공", + "operationId" : "delete-member-happiness-routine-docs", + "parameters" : [ { + "name" : "routineId", + "in" : "path", + "description" : "루틴 id", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "description" : "200", + "content" : { + "application/json;charset=UTF-8" : { + "schema" : { + "$ref" : "#/components/schemas/api-v1-auth594740350" + }, + "examples" : { + "delete-member-happiness-routine-docs" : { + "value" : "{\n \"success\" : true,\n \"message\" : \"루틴 삭제 성공\",\n \"data\" : null\n}" + } + } + } + } + } + } + }, + "patch" : { + "tags" : [ "MEMBER HAPPINESS ROUTINE" ], + "summary" : "회원 행복 루틴 달성 성공", + "description" : "회원 행복 루틴 달성 성공", + "operationId" : "achieve-member-happiness-routine-docs", + "parameters" : [ { + "name" : "routineId", + "in" : "path", + "description" : "루틴 id", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "description" : "200", + "content" : { + "application/json;charset=UTF-8" : { + "schema" : { + "$ref" : "#/components/schemas/api-v1-auth594740350" + }, + "examples" : { + "achieve-member-happiness-routine-docs" : { + "value" : "{\n \"success\" : true,\n \"message\" : \"회원 행복 루틴 달성 성공\",\n \"data\" : null\n}" + } + } + } + } + } + } + } + } + }, + "components" : { + "schemas" : { + "api-v1-members-980081866" : { + "type" : "object", + "properties" : { + "data" : { + "type" : "object", + "properties" : { + "dailyCottonCount" : { + "type" : "number", + "description" : "솜뭉치 개수" + }, + "name" : { + "type" : "string", + "description" : "인형 이름" + }, + "frameImageUrl" : { + "type" : "string", + "description" : "인형 배경 이미지 url" + }, + "dollType" : { + "type" : "string", + "description" : "인형 종류" + }, + "conversations" : { + "type" : "array", + "description" : "인형 대화 리스트", + "items" : { + "oneOf" : [ { + "type" : "object" + }, { + "type" : "boolean" + }, { + "type" : "string" + }, { + "type" : "number" + } ] + } + }, + "happinessCottonCount" : { + "type" : "number", + "description" : "행운 솜뭉치 개수" + } + }, + "description" : "응답 데이터" + }, + "success" : { + "type" : "boolean", + "description" : "응답 성공 여부" + }, + "message" : { + "type" : "string", + "description" : "응답 메시지" + } + } + }, + "api-v1-auth-token-831815960" : { + "type" : "object", + "properties" : { + "data" : { + "type" : "object", + "properties" : { + "accessToken" : { + "type" : "string", + "description" : "Access Token" + } + }, + "description" : "응답 데이터" + }, + "success" : { + "type" : "boolean", + "description" : "응답 성공 여부" + }, + "message" : { + "type" : "string", + "description" : "응답 메시지" + } + } + }, + "api-v1-routines-daily-54531209" : { + "type" : "object", + "properties" : { + "data" : { + "type" : "object", + "properties" : { + "routines" : { + "type" : "array", + "description" : "루틴 정보 리스트", + "items" : { + "type" : "object", + "properties" : { + "routineId" : { + "type" : "number", + "description" : "루틴 id" + }, + "content" : { + "type" : "string", + "description" : "테마 내용" + } + } + } + } + }, + "description" : "응답 데이터" + }, + "success" : { + "type" : "boolean", + "description" : "응답 성공 여부" + }, + "message" : { + "type" : "string", + "description" : "응답 메시지" + } + } + }, + "api-v1-routines-daily-member1721287602" : { + "type" : "object", + "properties" : { + "routineId" : { + "type" : "number", + "description" : "추가할 루틴 id" + } + } + }, + "api-v1-routines-daily-themes1975236624" : { + "type" : "object", + "properties" : { + "data" : { + "type" : "object", + "properties" : { + "themes" : { + "type" : "array", + "description" : "테마 정보 리스트", + "items" : { + "type" : "object", + "properties" : { + "iconImageUrl" : { + "type" : "string", + "description" : "아이콘 이미지 url" + }, + "name" : { + "type" : "string", + "description" : "테마 이름" + }, + "themeId" : { + "type" : "number", + "description" : "테마 id" + }, + "backgroundImageUrl" : { + "type" : "string", + "description" : "배경 이미지 url" + } + } + } + } + }, + "description" : "응답 데이터" + }, + "success" : { + "type" : "boolean", + "description" : "응답 성공 여부" + }, + "message" : { + "type" : "string", + "description" : "응답 메시지" + } + } + }, + "api-v1-routines-happiness-themes460193954" : { + "type" : "object", + "properties" : { + "data" : { + "type" : "object", + "properties" : { + "themes" : { + "type" : "array", + "description" : "테마 정보 리스트", + "items" : { + "type" : "object", + "properties" : { + "name" : { + "type" : "string", + "description" : "테마 이름" + }, + "themeId" : { + "type" : "number", + "description" : "테마 id" + } + } + } + } + }, + "description" : "응답 데이터" + }, + "success" : { + "type" : "boolean", + "description" : "응답 성공 여부" + }, + "message" : { + "type" : "string", + "description" : "응답 메시지" + } + } + }, + "api-v1-routines-daily-theme-themeId-1869963578" : { + "type" : "object", + "properties" : { + "data" : { + "type" : "object", + "properties" : { + "routines" : { + "type" : "array", + "description" : "루틴 정보 리스트", + "items" : { + "type" : "object", + "properties" : { + "routineId" : { + "type" : "number", + "description" : "루틴 id" + }, + "content" : { + "type" : "string", + "description" : "테마 내용" + } + } + } + }, + "backgroundImageUrl" : { + "type" : "string", + "description" : "테마 배경 이미지 url" + } + }, + "description" : "응답 데이터" + }, + "success" : { + "type" : "boolean", + "description" : "응답 성공 여부" + }, + "message" : { + "type" : "string", + "description" : "응답 메시지" + } + } + }, + "api-v1-auth594740350" : { + "type" : "object", + "properties" : { + "success" : { + "type" : "boolean", + "description" : "응답 성공 여부" + }, + "message" : { + "type" : "string", + "description" : "응답 메시지" + } + } + }, + "api-v1-members-cotton-cottonType-1561973557" : { + "type" : "object", + "properties" : { + "data" : { + "type" : "object", + "properties" : { + "cottonCount" : { + "type" : "number", + "description" : "남은 솜뭉치 개수" + } + }, + "description" : "응답 데이터" + }, + "success" : { + "type" : "boolean", + "description" : "응답 성공 여부" + }, + "message" : { + "type" : "string", + "description" : "응답 메시지" + } + } + }, + "api-v1-auth123505361" : { + "type" : "object", + "properties" : { + "data" : { + "type" : "object", + "properties" : { + "isMemberDollExist" : { + "type" : "boolean", + "description" : "프로필 존재 여부" + }, + "accessToken" : { + "type" : "string", + "description" : "Access Token" + }, + "refreshToken" : { + "type" : "string", + "description" : "Refresh Token" + } + }, + "description" : "응답 데이터" + }, + "success" : { + "type" : "boolean", + "description" : "응답 성공 여부" + }, + "message" : { + "type" : "string", + "description" : "응답 메시지" + } + } + }, + "api-v1-dolls-image-type-1785135499" : { + "type" : "object", + "properties" : { + "data" : { + "type" : "object", + "properties" : { + "faceImageUrl" : { + "type" : "string", + "description" : "인형 얼굴 이미지 url" + } + }, + "description" : "응답 데이터" + }, + "success" : { + "type" : "boolean", + "description" : "응답 성공 여부" + }, + "message" : { + "type" : "string", + "description" : "응답 메시지" + } + } + }, + "api-v1-auth-1040425838" : { + "type" : "object", + "properties" : { + "socialType" : { + "type" : "string", + "description" : "소셜 종류" + } + } + }, + "api-v1-routines-daily-member-routine-routineId-2021368021" : { + "type" : "object", + "properties" : { + "data" : { + "type" : "object", + "properties" : { + "achieveCount" : { + "type" : "number", + "description" : "달성 횟수" + }, + "routineId" : { + "type" : "number", + "description" : "루틴 id" + }, + "isAchieve" : { + "type" : "boolean", + "description" : "달성 여부" + } + }, + "description" : "응답 데이터" + }, + "success" : { + "type" : "boolean", + "description" : "응답 성공 여부" + }, + "message" : { + "type" : "string", + "description" : "응답 메시지" + } + } + }, + "api-v1-routines-happiness-routine-routineId2107530734" : { + "type" : "object", + "properties" : { + "data" : { + "type" : "object", + "properties" : { + "subRoutines" : { + "type" : "array", + "description" : "서브 루틴 정보 리스트", + "items" : { + "type" : "object", + "properties" : { + "timeTaken" : { + "type" : "string", + "description" : "소요 시간" + }, + "detailContent" : { + "type" : "string", + "description" : "서브 루틴 세부 내용" + }, + "place" : { + "type" : "string", + "description" : "장소" + }, + "subRoutineId" : { + "type" : "number", + "description" : "서브 루틴 id" + }, + "content" : { + "type" : "string", + "description" : "서브 루틴 내용" + } + } + } + }, + "contentImageUrl" : { + "type" : "string", + "description" : "카드 이미지 url" + }, + "iconImageUrl" : { + "type" : "string", + "description" : "아이콘 이미지 url" + }, + "nameColor" : { + "type" : "string", + "description" : "색깔" + }, + "name" : { + "type" : "string", + "description" : "테마 이름" + }, + "title" : { + "type" : "string", + "description" : "행복 루틴 주제" + } + }, + "description" : "응답 데이터" + }, + "success" : { + "type" : "boolean", + "description" : "응답 성공 여부" + }, + "message" : { + "type" : "string", + "description" : "응답 메시지" + } + } + }, + "api-v1-versions-client-app2103188655" : { + "type" : "object", + "properties" : { + "data" : { + "type" : "object", + "properties" : { + "notificationTitle" : { + "type" : "string", + "description" : "업데이트 알림 제목" + }, + "androidVersion" : { + "type" : "object", + "properties" : { + "appVersion" : { + "type" : "string", + "description" : "안드로이드 앱 버전" + }, + "forceUpdateVersion" : { + "type" : "string", + "description" : "안드로이드 강제 업데이트 버전" + } + }, + "description" : "안드로이드 버전 정보" + }, + "iosVersion" : { + "type" : "object", + "properties" : { + "appVersion" : { + "type" : "string", + "description" : "iOS 앱 버전" + }, + "forceUpdateVersion" : { + "type" : "string", + "description" : "iOS 강제 업데이트 버전" + } + }, + "description" : "iOS 버전 정보" + }, + "notificationContent" : { + "type" : "string", + "description" : "업데이트 알림 내용" + } + }, + "description" : "응답 데이터" + }, + "success" : { + "type" : "boolean", + "description" : "응답 성공 여부" + }, + "message" : { + "type" : "string", + "description" : "응답 메시지" + } + } + }, + "api-v1-routines-happiness-member-160843619" : { + "type" : "object", + "properties" : { + "data" : { + "type" : "object", + "properties" : { + "timeTaken" : { + "type" : "string", + "description" : "소요 시간" + }, + "detailContent" : { + "type" : "string", + "description" : "루틴 상세 내용" + }, + "themeName" : { + "type" : "string", + "description" : "테마 이름" + }, + "contentImageUrl" : { + "type" : "string", + "description" : "카드 이미지 url" + }, + "themeNameColor" : { + "type" : "string", + "description" : "테마 이름 색상" + }, + "iconImageUrl" : { + "type" : "string", + "description" : "아이콘 이미지 url" + }, + "place" : { + "type" : "string", + "description" : "장소" + }, + "title" : { + "type" : "string", + "description" : "루틴 제목" + }, + "routineId" : { + "type" : "number", + "description" : "루틴 id" + }, + "content" : { + "type" : "string", + "description" : "루틴 내용" + } + }, + "description" : "응답 데이터" + }, + "success" : { + "type" : "boolean", + "description" : "응답 성공 여부" + }, + "message" : { + "type" : "string", + "description" : "응답 메시지" + } + } + }, + "api-v1-routines-happiness-member1502060882" : { + "type" : "object", + "properties" : { + "subRoutineId" : { + "type" : "number", + "description" : "추가할 루틴 id" + } + } + }, + "api-v1-routines-happiness1133934080" : { + "type" : "object", + "properties" : { + "data" : { + "type" : "object", + "properties" : { + "routines" : { + "type" : "array", + "description" : "루틴 정보 리스트", + "items" : { + "type" : "object", + "properties" : { + "iconImageUrl" : { + "type" : "string", + "description" : "이미지 url" + }, + "nameColor" : { + "type" : "string", + "description" : "행복 루틴 제목 색상" + }, + "name" : { + "type" : "string", + "description" : "행복 루틴 제목" + }, + "title" : { + "type" : "string", + "description" : "행복 루틴 설명" + }, + "routineId" : { + "type" : "number", + "description" : "행복 루틴 id" + } + } + } + } + }, + "description" : "응답 데이터" + }, + "success" : { + "type" : "boolean", + "description" : "응답 성공 여부" + }, + "message" : { + "type" : "string", + "description" : "응답 메시지" + } + } + }, + "api-v1-routines-daily-member1327124516" : { + "type" : "object", + "properties" : { + "data" : { + "type" : "object", + "properties" : { + "routineId" : { + "type" : "number", + "description" : "생성한 루틴 id" + } + }, + "description" : "응답 데이터" + }, + "success" : { + "type" : "boolean", + "description" : "응답 성공 여부" + }, + "message" : { + "type" : "string", + "description" : "응답 메시지" + } + } + }, + "api-v1-members-2061462562" : { + "type" : "object", + "properties" : { + "name" : { + "type" : "string", + "description" : "인형 이름" + }, + "routines" : { + "type" : "array", + "description" : "인형 종류", + "items" : { + "oneOf" : [ { + "type" : "object" + }, { + "type" : "boolean" + }, { + "type" : "string" + }, { + "type" : "number" + } ] + } + }, + "dollType" : { + "type" : "string", + "description" : "인형 종류" + } + } + }, + "api-v1-routines-daily-member-336344243" : { + "type" : "object", + "properties" : { + "data" : { + "type" : "object", + "properties" : { + "routines" : { + "type" : "array", + "description" : "루틴 정보 리스트", + "items" : { + "type" : "object", + "properties" : { + "achieveCount" : { + "type" : "number", + "description" : "달성 횟수" + }, + "iconImageUrl" : { + "type" : "string", + "description" : "아이콘 이미지 url" + }, + "routineId" : { + "type" : "number", + "description" : "루틴 id" + }, + "content" : { + "type" : "string", + "description" : "테마 내용" + }, + "isAchieve" : { + "type" : "boolean", + "description" : "달성 여부" + } + } + } + } + }, + "description" : "응답 데이터" + }, + "success" : { + "type" : "boolean", + "description" : "응답 성공 여부" + }, + "message" : { + "type" : "string", + "description" : "응답 메시지" + } + } + }, + "api-v1-test486549215" : { + "type" : "object" + } + } + } +} \ No newline at end of file diff --git a/src/test/java/com/soptie/server/ServerApplicationTests.java b/src/test/java/com/soptie/server/ServerApplicationTests.java deleted file mode 100644 index f6bbae6b..00000000 --- a/src/test/java/com/soptie/server/ServerApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.soptie.server; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class ServerApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/src/test/java/com/soptie/server/auth/controller/AuthControllerTest.java b/src/test/java/com/soptie/server/auth/controller/AuthControllerTest.java new file mode 100644 index 00000000..4f5d6f7c --- /dev/null +++ b/src/test/java/com/soptie/server/auth/controller/AuthControllerTest.java @@ -0,0 +1,208 @@ +package com.soptie.server.auth.controller; + +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.soptie.server.auth.dto.SignInRequest; +import com.soptie.server.auth.dto.SignInResponse; +import com.soptie.server.auth.dto.TokenResponse; +import com.soptie.server.auth.vo.Token; +import com.soptie.server.base.BaseControllerTest; +import com.soptie.server.common.config.ValueConfig; +import com.soptie.server.common.dto.Response; +import com.soptie.server.member.entity.SocialType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +import java.net.URI; +import java.security.Principal; + +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static com.soptie.server.auth.message.SuccessMessage.*; +import static com.soptie.server.common.dto.Response.success; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.*; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.JsonFieldType.*; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(AuthController.class) +class AuthControllerTest extends BaseControllerTest { + + @MockBean + AuthController controller; + @MockBean + Principal principal; + @MockBean + ValueConfig valueConfig; + + private final String DEFAULT_URL = "/api/v1/auth"; + private final String TAG = "AUTH"; + + @Test + @DisplayName("소셜 로그인 성공") + void success_getTokenBySocialAccessToken() throws Exception { + // given + String socialAccessToken = "Bearer softietoken"; + SignInRequest request = SignInRequest.of(SocialType.KAKAO); + SignInResponse response = SignInResponse.of( + Token.builder() + .accessToken("softie") + .refreshToken("token") + .build(), false + ); + ResponseEntity result = ResponseEntity.ok(success(SUCCESS_SIGN_IN.getMessage(), response)); + + // when + when(controller.signIn(socialAccessToken, request)).thenReturn(result); + + // then + mockMvc + .perform( + post(DEFAULT_URL) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .header("Authorization", socialAccessToken) + .content(objectMapper.writeValueAsString(request))) + .andDo( + document( + "post-token-docs", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag(TAG) + .description("소셜 로그인") + .requestHeaders( + headerWithName("Authorization").description("Social Access Token") + ) + .requestFields( + fieldWithPath("socialType").type(STRING).description("소셜 종류")) + .responseFields( + fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"), + fieldWithPath("message").type(STRING).description("응답 메시지"), + fieldWithPath("data").type(OBJECT).description("응답 데이터"), + fieldWithPath("data.accessToken").type(STRING).description("Access Token"), + fieldWithPath("data.refreshToken").type(STRING).description("Refresh Token"), + fieldWithPath("data.isMemberDollExist").type(BOOLEAN).description("프로필 존재 여부") + ) + .build()))) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("토큰 재발급 성공") + void success_reissueToken() throws Exception { + // given + String refreshToken = "refreshToken"; + String accessToken = "accessToken"; + TokenResponse response = TokenResponse.of(accessToken); + ResponseEntity result = ResponseEntity.ok(success(SUCCESS_RECREATE_TOKEN.getMessage(), response)); + + // when + when(controller.reissueToken(refreshToken)).thenReturn(result); + + // then + mockMvc + .perform( + post(DEFAULT_URL + "/token") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .header("Authorization", refreshToken) + ) + .andDo( + document( + "post-recreate-token-docs", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag(TAG) + .description("토큰 재발급") + .responseFields( + fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"), + fieldWithPath("message").type(STRING).description("응답 메시지"), + fieldWithPath("data").type(OBJECT).description("응답 데이터"), + fieldWithPath("data.accessToken").type(STRING).description("Access Token") + ) + .build()))) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("로그아웃 성공") + void success_signOut() throws Exception { + // given + ResponseEntity result = ResponseEntity.ok(success(SUCCESS_SIGN_OUT.getMessage(), null)); + + // when + when(controller.signOut(principal)).thenReturn(result); + + // then + mockMvc + .perform( + post(DEFAULT_URL + "/logout") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .principal(principal) + ) + .andDo( + document( + "post-logout-docs", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag(TAG) + .description("로그아웃") + .responseFields( + fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"), + fieldWithPath("message").type(STRING).description("응답 메시지"), + fieldWithPath("data").type(NULL).description("응답 데이터") + ) + .build()))) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("회원탈퇴 성공") + void success_withdrawal() throws Exception { + // given + ResponseEntity result = ResponseEntity.ok(success(SUCCESS_WITHDRAWAL.getMessage())); + + // when + when(controller.withdrawal(principal)).thenReturn(result); + + // then + mockMvc + .perform( + delete(DEFAULT_URL) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .principal(principal) + ) + .andDo( + document( + "delete-withdrawal-docs", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag(TAG) + .description("회원탈퇴") + .responseFields( + fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"), + fieldWithPath("message").type(STRING).description("응답 메시지"), + fieldWithPath("data").type(NULL).description("응답 데이터") + ) + .build()))) + .andExpect(status().isOk()); + } +} diff --git a/src/test/java/com/soptie/server/base/BaseControllerTest.java b/src/test/java/com/soptie/server/base/BaseControllerTest.java new file mode 100644 index 00000000..010901fa --- /dev/null +++ b/src/test/java/com/soptie/server/base/BaseControllerTest.java @@ -0,0 +1,50 @@ +package com.soptie.server.base; + +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.filter.CharacterEncodingFilter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.soptie.server.auth.jwt.CustomJwtAuthenticationEntryPoint; +import com.soptie.server.auth.jwt.JwtTokenProvider; + +@AutoConfigureMockMvc +@AutoConfigureRestDocs +@ExtendWith({RestDocumentationExtension.class}) +@WebMvcTest(properties = "spring.config.location=classpath:/application.yml") +public abstract class BaseControllerTest { + + @Autowired + protected WebApplicationContext context; + @Autowired + protected ObjectMapper objectMapper; + @Autowired + protected MockMvc mockMvc; + + @MockBean + private JwtTokenProvider jwtTokenProvider; + @MockBean + private CustomJwtAuthenticationEntryPoint customJwtAuthenticationEntryPoint; + + @BeforeEach + void setUp(final RestDocumentationContextProvider restDocumentation) { + mockMvc = MockMvcBuilders.webAppContextSetup(context) + .apply(documentationConfiguration(restDocumentation)) + .addFilters(new CharacterEncodingFilter("UTF-8", true)) + .alwaysDo(print()) + .build(); + } +} diff --git a/src/test/java/com/soptie/server/base/BaseRepositoryTest.java b/src/test/java/com/soptie/server/base/BaseRepositoryTest.java new file mode 100644 index 00000000..84db856a --- /dev/null +++ b/src/test/java/com/soptie/server/base/BaseRepositoryTest.java @@ -0,0 +1,11 @@ +package com.soptie.server.base; + +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +import com.soptie.server.config.TestJpaQueryFactoryConfig; + +@Import(TestJpaQueryFactoryConfig.class) +@DataJpaTest +public abstract class BaseRepositoryTest { +} diff --git a/src/test/java/com/soptie/server/base/BaseServiceTest.java b/src/test/java/com/soptie/server/base/BaseServiceTest.java new file mode 100644 index 00000000..f1d2ce93 --- /dev/null +++ b/src/test/java/com/soptie/server/base/BaseServiceTest.java @@ -0,0 +1,8 @@ +package com.soptie.server.base; + +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +public abstract class BaseServiceTest { +} diff --git a/src/test/java/com/soptie/server/config/TestJpaQueryFactoryConfig.java b/src/test/java/com/soptie/server/config/TestJpaQueryFactoryConfig.java new file mode 100644 index 00000000..e62ff56d --- /dev/null +++ b/src/test/java/com/soptie/server/config/TestJpaQueryFactoryConfig.java @@ -0,0 +1,23 @@ +package com.soptie.server.config; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +import com.querydsl.jpa.impl.JPAQueryFactory; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; + +@EnableJpaAuditing +@TestConfiguration +public class TestJpaQueryFactoryConfig { + + @PersistenceContext + private EntityManager entityManager; + + @Bean + JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} diff --git a/src/test/java/com/soptie/server/doll/controller/DollControllerTest.java b/src/test/java/com/soptie/server/doll/controller/DollControllerTest.java new file mode 100644 index 00000000..5d9c0f7b --- /dev/null +++ b/src/test/java/com/soptie/server/doll/controller/DollControllerTest.java @@ -0,0 +1,78 @@ +package com.soptie.server.doll.controller; + +import static com.epages.restdocs.apispec.ResourceDocumentation.*; +import static org.mockito.Mockito.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.JsonFieldType.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; + +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.soptie.server.base.BaseControllerTest; +import com.soptie.server.common.config.ValueConfig; +import com.soptie.server.common.dto.Response; +import com.soptie.server.doll.dto.DollImageResponse; +import com.soptie.server.doll.entity.DollType; +import com.soptie.server.doll.fixture.DollFixture; + +@WebMvcTest(DollController.class) +class DollControllerTest extends BaseControllerTest { + + @MockBean + DollController controller; + @MockBean + ValueConfig valueConfig; + + private final String DEFAULT_URL = "/api/v1/dolls"; + private final String TAG = "DOLL"; + + @Test + @DisplayName("인형 이미지 조회 성공") + void success_getDollImage() throws Exception { + // given + DollType dollType = DollType.BROWN; + DollImageResponse dollImage = DollFixture.createDollImageResponseDTO(); + ResponseEntity response = ResponseEntity.ok(Response.success("인형 이미지 조회 성공", dollImage)); + + // when + when(controller.getDollImages(dollType)).thenReturn(response); + + // then + mockMvc + .perform( + RestDocumentationRequestBuilders.get(DEFAULT_URL + "/image/{type}", dollType) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andDo( + MockMvcRestDocumentation.document( + "get-doll-image-docs", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag(TAG) + .description("인형 이미지 조회") + .pathParameters( + parameterWithName("type").description("인형 타입") + ) + .requestFields() + .responseFields( + fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"), + fieldWithPath("message").type(STRING).description("응답 메시지"), + fieldWithPath("data").type(OBJECT).description("응답 데이터"), + fieldWithPath("data.faceImageUrl").type(STRING).description("인형 얼굴 이미지 url") + ) + .build()))) + .andExpect(status().isOk()); + } +} \ No newline at end of file diff --git a/src/test/java/com/soptie/server/doll/fixture/DollFixture.java b/src/test/java/com/soptie/server/doll/fixture/DollFixture.java new file mode 100644 index 00000000..b27e7c2b --- /dev/null +++ b/src/test/java/com/soptie/server/doll/fixture/DollFixture.java @@ -0,0 +1,12 @@ +package com.soptie.server.doll.fixture; + +import com.soptie.server.doll.dto.DollImageResponse; + +public class DollFixture { + + private static final String FACE_IMAGE_URL = "face-image-url"; + + public static DollImageResponse createDollImageResponseDTO() { + return new DollImageResponse(FACE_IMAGE_URL); + } +} diff --git a/src/test/java/com/soptie/server/member/controller/MemberControllerTest.java b/src/test/java/com/soptie/server/member/controller/MemberControllerTest.java new file mode 100644 index 00000000..ecd3d6b3 --- /dev/null +++ b/src/test/java/com/soptie/server/member/controller/MemberControllerTest.java @@ -0,0 +1,181 @@ +package com.soptie.server.member.controller; + +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.soptie.server.base.BaseControllerTest; +import com.soptie.server.common.config.ValueConfig; +import com.soptie.server.common.dto.Response; +import com.soptie.server.member.dto.CottonCountResponse; +import com.soptie.server.member.dto.MemberProfileRequest; +import com.soptie.server.member.dto.MemberHomeInfoResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation; + +import java.net.URI; +import java.security.Principal; +import java.util.List; + +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static com.soptie.server.common.dto.Response.success; +import static com.soptie.server.doll.entity.DollType.BROWN; +import static com.soptie.server.member.entity.CottonType.DAILY; +import static com.soptie.server.member.message.SuccessMessage.SUCCESS_CREATE_PROFILE; +import static com.soptie.server.member.message.SuccessMessage.SUCCESS_GIVE_COTTON; +import static com.soptie.server.member.message.SuccessMessage.*; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.JsonFieldType.*; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(MemberController.class) +class MemberControllerTest extends BaseControllerTest { + + @MockBean + MemberController controller; + @MockBean + Principal principal; + @MockBean + ValueConfig valueConfig; + + private final String DEFAULT_URL = "/api/v1/members"; + private final String TAG = "MEMBER"; + + @Test + @DisplayName("프로필을 생성한다.") + void success_createMemberProfile() throws Exception { + // given + MemberProfileRequest request = new MemberProfileRequest(BROWN, "소프티", List.of(1L, 2L, 3L)); + ResponseEntity response = ResponseEntity.created(URI.create("redirect_uri")) + .body(success(SUCCESS_CREATE_PROFILE.getMessage(), null)); + + // when + when(controller.createMemberProfile(principal, request)).thenReturn(response); + + // then + mockMvc.perform(post(DEFAULT_URL) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .principal(principal) + .content(objectMapper.writeValueAsString(request))) + .andDo( + MockMvcRestDocumentation.document( + "post-member-profile-docs", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag(TAG) + .description("프로필 생성") + .requestFields( + fieldWithPath("dollType").type(STRING).description("인형 종류"), + fieldWithPath("name").type(STRING).description("인형 이름"), + fieldWithPath("routines").type(ARRAY).description("인형 종류") + ) + .responseHeaders( + headerWithName("Location").description("Redirect URI") + ) + .responseFields( + fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"), + fieldWithPath("message").type(STRING).description("응답 메시지"), + fieldWithPath("data").type(NULL).description("응답 데이터") + ) + .build()))) + .andExpect(status().isCreated()); + } + + @Test + @DisplayName("홈 화면을 불러온다.") + void success_getMemberHomeInfo() throws Exception { + // given + MemberHomeInfoResponse response = MemberHomeInfoResponse.builder() + .name("softie") + .dollType(BROWN) + .frameImageUrl("https://...") + .dailyCottonCount(0) + .happinessCottonCount(0) + .conversations(List.of("안녕", "하이", "봉쥬르")) + .build(); + ResponseEntity result = ResponseEntity.ok((success(SUCCESS_HOME_INFO.getMessage(), response))); + + // when + when(controller.getMemberHomeInfo(principal)).thenReturn(result); + + // then + mockMvc.perform(get(DEFAULT_URL) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .principal(principal)) + .andDo( + MockMvcRestDocumentation.document( + "get-member-home-screen-docs", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag(TAG) + .description("홈 화면 불러오기") + .responseFields( + fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"), + fieldWithPath("message").type(STRING).description("응답 메시지"), + fieldWithPath("data").type(OBJECT).description("응답 데이터"), + fieldWithPath("data.name").type(STRING).description("인형 이름"), + fieldWithPath("data.dollType").type(STRING).description("인형 종류"), + fieldWithPath("data.frameImageUrl").type(STRING).description("인형 배경 이미지 url"), + fieldWithPath("data.dailyCottonCount").type(NUMBER).description("솜뭉치 개수"), + fieldWithPath("data.happinessCottonCount").type(NUMBER).description("행운 솜뭉치 개수"), + fieldWithPath("data.conversations").type(ARRAY).description("인형 대화 리스트") + ) + .build()))) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("솜뭉치 주기") + void success_giveCotton() throws Exception { + // given + CottonCountResponse response = CottonCountResponse.of(0); + ResponseEntity result = ResponseEntity.ok(Response.success(SUCCESS_GIVE_COTTON.getMessage(), response)); + + // when + when(controller.giveCotton(principal, DAILY)).thenReturn(result); + + // then + mockMvc + .perform( + patch(DEFAULT_URL + "/cotton/{cottonType}", DAILY) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .principal(principal)) + .andDo( + MockMvcRestDocumentation.document( + "give-cotton-docs", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag(TAG) + .description("솜뭉치 주기") + .pathParameters( + parameterWithName("cottonType").description("솜뭉치 종류") + ) + .requestFields() + .responseFields( + fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"), + fieldWithPath("message").type(STRING).description("응답 메시지"), + fieldWithPath("data").type(OBJECT).description("응답 데이터"), + fieldWithPath("data.cottonCount").type(NUMBER).description("남은 솜뭉치 개수") + ) + .build()))) + .andExpect(status().isOk()); + } +} diff --git a/src/test/java/com/soptie/server/memberRoutine/controller/MemberDailyRoutineControllerTest.java b/src/test/java/com/soptie/server/memberRoutine/controller/MemberDailyRoutineControllerTest.java new file mode 100644 index 00000000..06cbb312 --- /dev/null +++ b/src/test/java/com/soptie/server/memberRoutine/controller/MemberDailyRoutineControllerTest.java @@ -0,0 +1,220 @@ +package com.soptie.server.memberRoutine.controller; + +import static com.epages.restdocs.apispec.ResourceDocumentation.*; +import static com.soptie.server.common.dto.Response.*; +import static org.mockito.Mockito.*; +import static org.springframework.http.MediaType.*; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.*; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.JsonFieldType.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.net.URI; +import java.security.Principal; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.soptie.server.base.BaseControllerTest; +import com.soptie.server.common.config.ValueConfig; +import com.soptie.server.common.dto.Response; +import com.soptie.server.memberRoutine.dto.AchievedMemberDailyRoutineResponse; +import com.soptie.server.memberRoutine.dto.MemberDailyRoutineRequest; +import com.soptie.server.memberRoutine.dto.MemberDailyRoutineResponse; +import com.soptie.server.memberRoutine.dto.MemberDailyRoutinesResponse; +import com.soptie.server.memberRoutine.fixture.MemberDailyRoutineFixture; + +@WebMvcTest(MemberDailyRoutineController.class) +class MemberDailyRoutineControllerTest extends BaseControllerTest { + + @MockBean + MemberDailyRoutineController controller; + @MockBean + Principal principal; + @MockBean + ValueConfig valueConfig; + + private final String DEFAULT_URL = "/api/v1/routines/daily/member"; + private final String TAG = "MEMBER DAILY ROUTINE"; + + @Test + @DisplayName("회원 데일리 루틴 추가 성공") + void success_createMemberDailyRoutine() throws Exception { + // given + MemberDailyRoutineRequest request = new MemberDailyRoutineRequest(1L); + MemberDailyRoutineResponse savedMemberRoutine = MemberDailyRoutineFixture.createMemberDailyRoutineResponseDTO(); + ResponseEntity response = ResponseEntity + .created(URI.create("redirect_uri")) + .body(success("루틴 추가 성공", savedMemberRoutine)); + + // when + when(controller.createMemberDailyRoutine(principal, request)).thenReturn(response); + + // then + mockMvc.perform(post(DEFAULT_URL) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .principal(principal) + .content(objectMapper.writeValueAsString(request)) + ) + .andDo( + document("post-member-daily-routine-docs", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource(ResourceSnippetParameters.builder() + .tag(TAG) + .description("회원 데일리 루틴 추가 성공") + .requestFields( + fieldWithPath("routineId").type(NUMBER).description("추가할 루틴 id") + ) + .responseHeaders( + headerWithName("Location").description("Redirect URI") + ) + .responseFields( + fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"), + fieldWithPath("message").type(STRING).description("응답 메시지"), + fieldWithPath("data").type(OBJECT).description("응답 데이터"), + fieldWithPath("data.routineId").type(NUMBER).description("생성한 루틴 id") + ) + .build() + ) + )) + .andExpect(status().isCreated()); + } + + @Test + @DisplayName("회원 데일리 루틴 삭제") + void success_deleteMemberDailyRoutines() throws Exception { + // given + ResponseEntity response = ResponseEntity.ok(success("루틴 삭제 성공")); + MultiValueMap queries = new LinkedMultiValueMap<>(); + String routines = "1,2,3"; + queries.add("routines", routines); + + // when + when(controller.deleteMemberDailyRoutines(principal, List.of(1L, 2L, 3L))).thenReturn(response); + + // then + mockMvc.perform(delete(DEFAULT_URL) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .principal(principal) + .queryParams(queries)) + .andDo( + document("delete-member-daily-routines-docs", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource(ResourceSnippetParameters.builder() + .tag(TAG) + .description("회원 데일리 루틴 삭제") + .queryParameters( + parameterWithName("routines").description("삭제할 루틴 id 리스트") + ) + .responseFields( + fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"), + fieldWithPath("message").type(STRING).description("응답 메시지"), + fieldWithPath("data").type(NULL).description("응답 데이터") + ) + .build() + ) + )) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("루틴 달성 성공") + void success_achieveMemberDailyRoutine() throws Exception { + // given + Long routineId = 1L; + AchievedMemberDailyRoutineResponse memberRoutine = new AchievedMemberDailyRoutineResponse( + routineId, true, 1); + ResponseEntity response = ResponseEntity.ok(success("루틴 달성 성공", memberRoutine)); + + // when + when(controller.achieveMemberDailyRoutine(principal, routineId)).thenReturn(response); + + // then + mockMvc.perform(patch(DEFAULT_URL + "/routine/{routineId}", routineId) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .principal(principal)) + .andDo( + document("achieve-member-daily-routine-docs", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource(ResourceSnippetParameters.builder() + .tag(TAG) + .description("회원 데일리 루틴 달성 성공") + .pathParameters( + parameterWithName("routineId").description("루틴 id") + ) + .responseFields( + fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"), + fieldWithPath("message").type(STRING).description("응답 메시지"), + fieldWithPath("data").type(OBJECT).description("응답 데이터"), + fieldWithPath("data.routineId").type(NUMBER).description("루틴 id"), + fieldWithPath("data.isAchieve").type(BOOLEAN).description("달성 여부"), + fieldWithPath("data.achieveCount").type(NUMBER).description("달성 횟수") + ) + .build() + ) + )) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("회원 별 데일리 루틴 리스트 조회 성공") + void success_getMemberDailyRoutines() throws Exception { + // given + MemberDailyRoutinesResponse routines = MemberDailyRoutineFixture.createMemberDailyRoutinesResponseDTO(); + ResponseEntity response = ResponseEntity.ok(Response.success("루틴 조회 성공", routines)); + + // when + when(controller.getMemberDailyRoutines(principal)).thenReturn(response); + + // then + mockMvc + .perform( + RestDocumentationRequestBuilders.get(DEFAULT_URL) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .principal(principal)) + .andDo( + MockMvcRestDocumentation.document( + "get-member-daily-routines-docs", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag(TAG) + .description("회원 별 데일리 루틴 리스트 조회") + .requestFields() + .responseFields( + fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"), + fieldWithPath("message").type(STRING).description("응답 메시지"), + fieldWithPath("data").type(OBJECT).description("응답 데이터"), + fieldWithPath("data.routines").type(ARRAY).description("루틴 정보 리스트"), + fieldWithPath("data.routines[].routineId").type(NUMBER).description("루틴 id"), + fieldWithPath("data.routines[].content").type(STRING).description("테마 내용"), + fieldWithPath("data.routines[].iconImageUrl").type(STRING).description("아이콘 이미지 url"), + fieldWithPath("data.routines[].achieveCount").type(NUMBER).description("달성 횟수"), + fieldWithPath("data.routines[].isAchieve").type(BOOLEAN).description("달성 여부") + ) + .build()))) + .andExpect(status().isOk()); + } +} \ No newline at end of file diff --git a/src/test/java/com/soptie/server/memberRoutine/controller/MemberHappinessRoutineControllerTest.java b/src/test/java/com/soptie/server/memberRoutine/controller/MemberHappinessRoutineControllerTest.java new file mode 100644 index 00000000..9a9db831 --- /dev/null +++ b/src/test/java/com/soptie/server/memberRoutine/controller/MemberHappinessRoutineControllerTest.java @@ -0,0 +1,205 @@ +package com.soptie.server.memberRoutine.controller; + +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.soptie.server.base.BaseControllerTest; +import com.soptie.server.common.config.ValueConfig; +import com.soptie.server.common.dto.Response; +import com.soptie.server.memberRoutine.dto.MemberHappinessRoutineRequest; +import com.soptie.server.memberRoutine.dto.MemberHappinessRoutineResponse; +import com.soptie.server.memberRoutine.dto.MemberHappinessRoutinesResponse; +import com.soptie.server.memberRoutine.fixture.MemberHappinessRoutineFixture; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; + +import java.net.URI; +import java.security.Principal; + +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static com.soptie.server.common.dto.Response.success; +import static org.mockito.Mockito.when; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.JsonFieldType.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(MemberHappinessRoutineController.class) +class MemberHappinessRoutineControllerTest extends BaseControllerTest { + + @MockBean + MemberHappinessRoutineController controller; + @MockBean + Principal principal; + @MockBean + ValueConfig valueConfig; + + private final String DEFAULT_URL = "/api/v1/routines/happiness/member"; + private final String TAG = "MEMBER HAPPINESS ROUTINE"; + + @Test + @DisplayName("회원 별 행복 루틴 조회 성공") + void success_getMemberHappinessRoutines() throws Exception { + // given + MemberHappinessRoutinesResponse routines = MemberHappinessRoutineFixture.createMemberHappinessRoutinesResponseDTO(); + ResponseEntity response = ResponseEntity.ok(Response.success("루틴 조회 성공", routines)); + + // when + when(controller.getMemberHappinessRoutine(principal)).thenReturn(response); + + // then + mockMvc + .perform( + RestDocumentationRequestBuilders.get(DEFAULT_URL) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .principal(principal)) + .andDo( + MockMvcRestDocumentation.document( + "get-member-happiness-routine-docs", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag(TAG) + .description("회원 별 행복 루틴 리스트 조회") + .requestFields() + .responseFields( + fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"), + fieldWithPath("message").type(STRING).description("응답 메시지"), + fieldWithPath("data").type(OBJECT).description("응답 데이터"), + fieldWithPath("data.routineId").type(NUMBER).description("루틴 id"), + fieldWithPath("data.iconImageUrl").type(STRING).description("아이콘 이미지 url"), + fieldWithPath("data.contentImageUrl").type(STRING).description("카드 이미지 url"), + fieldWithPath("data.themeName").type(STRING).description("테마 이름"), + fieldWithPath("data.themeNameColor").type(STRING).description("테마 이름 색상"), + fieldWithPath("data.title").type(STRING).description("루틴 제목"), + fieldWithPath("data.content").type(STRING).description("루틴 내용"), + fieldWithPath("data.detailContent").type(STRING).description("루틴 상세 내용"), + fieldWithPath("data.place").type(STRING).description("장소"), + fieldWithPath("data.timeTaken").type(STRING).description("소요 시간") + ) + .build()))) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("회원 행복 루틴 추가 성공") + void success_createMemberHappinessRoutine() throws Exception { + + MemberHappinessRoutineRequest request = new MemberHappinessRoutineRequest(1L); + MemberHappinessRoutineResponse savedMemberRoutine = MemberHappinessRoutineFixture.createMemberHappinessRoutineResponseDTO(); + ResponseEntity response = ResponseEntity + .created(URI.create("redirect_uri")) + .body(success("루틴 추가 성공", savedMemberRoutine)); + + when(controller.createMemberHappinessRoutine(principal, request)).thenReturn(response); + + mockMvc.perform(post(DEFAULT_URL) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .principal(principal) + .content(objectMapper.writeValueAsString(request)) + ) + .andDo( + document("post-member-happiness-routine-docs", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource(ResourceSnippetParameters.builder() + .tag(TAG) + .description("회원 행복 루틴 추가 성공") + .requestFields( + fieldWithPath("subRoutineId").type(NUMBER).description("추가할 루틴 id") + ) + .responseHeaders( + headerWithName("Location").description("Redirect URI") + ) + .responseFields( + fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"), + fieldWithPath("message").type(STRING).description("응답 메시지"), + fieldWithPath("data").type(OBJECT).description("응답 데이터"), + fieldWithPath("data.routineId").type(NUMBER).description("생성한 루틴 id") + ) + .build() + ) + )) + .andExpect(status().isCreated()); + } + + @Test + @DisplayName("회원 행복 루틴 삭제 성공") + void success_deleteMemberHappinessRoutine() throws Exception { + + Long routineId = 1L; + ResponseEntity response = ResponseEntity.ok(success("루틴 삭제 성공")); + + when(controller.deleteMemberHappinessRoutine(principal, routineId)).thenReturn(response); + + mockMvc.perform(delete(DEFAULT_URL + "/routine/{routineId}", routineId) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .principal(principal)) + .andDo( + document("delete-member-happiness-routine-docs", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource(ResourceSnippetParameters.builder() + .tag(TAG) + .description("회원 행복 루틴 삭제 성공") + .pathParameters( + parameterWithName("routineId").description("루틴 id") + ) + .responseFields( + fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"), + fieldWithPath("message").type(STRING).description("응답 메시지"), + fieldWithPath("data").type(NULL).description("응답 데이터") + ) + .build() + ) + )) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("회원 행복 루틴 달성 성공") + void success_achieveMemberHappinessRoutine() throws Exception { + + Long routineId = 1L; + ResponseEntity response = ResponseEntity.ok(success("회원 행복 루틴 달성 성공")); + + when(controller.achieveMemberHappinessRoutine(principal, routineId)).thenReturn(response); + + mockMvc.perform(patch(DEFAULT_URL + "/routine/{routineId}", routineId) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .principal(principal)) + .andDo( + document("achieve-member-happiness-routine-docs", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource(ResourceSnippetParameters.builder() + .tag(TAG) + .description("회원 행복 루틴 달성 성공") + .pathParameters( + parameterWithName("routineId").description("루틴 id") + ) + .responseFields( + fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"), + fieldWithPath("message").type(STRING).description("응답 메시지"), + fieldWithPath("data").type(NULL).description("응답 데이터") + ) + .build() + ) + )) + .andExpect(status().isOk()); + } +} \ No newline at end of file diff --git a/src/test/java/com/soptie/server/memberRoutine/fixture/MemberDailyRoutineFixture.java b/src/test/java/com/soptie/server/memberRoutine/fixture/MemberDailyRoutineFixture.java new file mode 100644 index 00000000..69f4453c --- /dev/null +++ b/src/test/java/com/soptie/server/memberRoutine/fixture/MemberDailyRoutineFixture.java @@ -0,0 +1,35 @@ +package com.soptie.server.memberRoutine.fixture; + +import java.util.List; +import java.util.stream.Stream; + +import com.soptie.server.memberRoutine.dto.MemberDailyRoutineResponse; +import com.soptie.server.memberRoutine.dto.MemberDailyRoutinesResponse; + +public class MemberDailyRoutineFixture { + + + public static MemberDailyRoutineResponse createMemberDailyRoutineResponseDTO() { + return new MemberDailyRoutineResponse(1L); + } + + public static MemberDailyRoutinesResponse createMemberDailyRoutinesResponseDTO() { + return new MemberDailyRoutinesResponse(createDailyRoutineResponses()); + } + + private static List createDailyRoutineResponses() { + return Stream.iterate(1, i -> i + 1).limit(5) + .map(MemberDailyRoutineFixture::createDailyRoutineResponse) + .toList(); + } + + private static MemberDailyRoutinesResponse.MemberDailyRoutineResponse createDailyRoutineResponse(int i) { + return MemberDailyRoutinesResponse.MemberDailyRoutineResponse.builder() + .routineId(i) + .content("routine content" + i) + .iconImageUrl("https://...") + .achieveCount(i) + .isAchieve(i % 2 == 0) + .build(); + } +} diff --git a/src/test/java/com/soptie/server/memberRoutine/fixture/MemberHappinessRoutineFixture.java b/src/test/java/com/soptie/server/memberRoutine/fixture/MemberHappinessRoutineFixture.java new file mode 100644 index 00000000..2e4aa2b5 --- /dev/null +++ b/src/test/java/com/soptie/server/memberRoutine/fixture/MemberHappinessRoutineFixture.java @@ -0,0 +1,37 @@ +package com.soptie.server.memberRoutine.fixture; + +import com.soptie.server.memberRoutine.dto.MemberHappinessRoutineResponse; +import com.soptie.server.memberRoutine.dto.MemberHappinessRoutinesResponse; + +public class MemberHappinessRoutineFixture { + + private static final String CONTENT = "routine-content"; + private static final String DETAIL_CONTENT = "routine-detail-content"; + private static final String TIME_TAKEN = "5~10분 소요"; + private static final String PLACE = "루틴 장소"; + private static final String TITLE = "루틴 제목"; + private static final String NAME = "태마 이름"; + private static final String NAME_COLOR = "#ffcdc5"; + private static final String ICON_IMAGE_URL = "icon-image-url"; + private static final String CONTENT_IMAGE_URL = "content-image-url"; + + public static MemberHappinessRoutinesResponse createMemberHappinessRoutinesResponseDTO() { + return MemberHappinessRoutinesResponse.builder() + .routineId(1L) + .iconImageUrl(ICON_IMAGE_URL) + .contentImageUrl(CONTENT_IMAGE_URL) + .themeName(NAME) + .themeNameColor(NAME_COLOR) + .title(TITLE) + .content(CONTENT) + .detailContent(DETAIL_CONTENT) + .place(PLACE) + .timeTaken(TIME_TAKEN) + .build(); + + } + + public static MemberHappinessRoutineResponse createMemberHappinessRoutineResponseDTO() { + return new MemberHappinessRoutineResponse(1L); + } +} diff --git a/src/test/java/com/soptie/server/routine/controller/DailyRoutineControllerTest.java b/src/test/java/com/soptie/server/routine/controller/DailyRoutineControllerTest.java new file mode 100644 index 00000000..93e926ef --- /dev/null +++ b/src/test/java/com/soptie/server/routine/controller/DailyRoutineControllerTest.java @@ -0,0 +1,176 @@ +package com.soptie.server.routine.controller; + +import static com.epages.restdocs.apispec.ResourceDocumentation.*; +import static org.mockito.Mockito.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.JsonFieldType.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.security.Principal; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.soptie.server.base.BaseControllerTest; +import com.soptie.server.common.config.ValueConfig; +import com.soptie.server.common.dto.Response; +import com.soptie.server.routine.dto.DailyRoutinesByThemeResponse; +import com.soptie.server.routine.dto.DailyRoutinesByThemesResponse; +import com.soptie.server.routine.dto.DailyThemesResponse; +import com.soptie.server.routine.fixture.DailyRoutineFixture; + +@WebMvcTest(DailyRoutineController.class) +class DailyRoutineControllerTest extends BaseControllerTest { + + @MockBean + DailyRoutineController controller; + @MockBean + Principal principal; + @MockBean + ValueConfig valueConfig; + + private final String DEFAULT_URL = "/api/v1/routines/daily"; + private final String TAG = "DAILY ROUTINE"; + + @Test + @DisplayName("데일리 루틴 테마 리스트 조회 성공") + void success_getDailyThemes() throws Exception { + // given + DailyThemesResponse themes = DailyRoutineFixture.createDailyThemesResponseDTO(); + ResponseEntity response = ResponseEntity.ok(Response.success("테마 조회 성공", themes)); + + // when + when(controller.getThemes()).thenReturn(response); + + // then + mockMvc + .perform( + RestDocumentationRequestBuilders.get(DEFAULT_URL + "/themes") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andDo( + MockMvcRestDocumentation.document( + "get-daily-themes-docs", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag(TAG) + .description("데일리 루틴 테마 리스트 조회") + .requestFields() + .responseFields( + fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"), + fieldWithPath("message").type(STRING).description("응답 메시지"), + fieldWithPath("data").type(OBJECT).description("응답 데이터"), + fieldWithPath("data.themes").type(ARRAY).description("테마 정보 리스트"), + fieldWithPath("data.themes[].themeId").type(NUMBER).description("테마 id"), + fieldWithPath("data.themes[].name").type(STRING).description("테마 이름"), + fieldWithPath("data.themes[].iconImageUrl").type(STRING).description("아이콘 이미지 url"), + fieldWithPath("data.themes[].backgroundImageUrl").type(STRING).description("배경 이미지 url") + ) + .build()))) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("테마 리스트 별 데일리 루틴 리스트 조회 성공") + void success_getDailyRoutinesByThemes() throws Exception { + // given + DailyRoutinesByThemesResponse routines = DailyRoutineFixture.createDailyRoutinesByThemesResponseDTO(); + ResponseEntity response = ResponseEntity.ok(Response.success("루틴 조회 성공", routines)); + + MultiValueMap queries = new LinkedMultiValueMap<>(); + String themes = "1,2,3"; + queries.add("themes", themes); + + // when + when(controller.getRoutinesByThemes(List.of(1L, 2L, 3L))).thenReturn(response); + + // then + mockMvc + .perform( + RestDocumentationRequestBuilders.get(DEFAULT_URL) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .params(queries)) + .andDo( + MockMvcRestDocumentation.document( + "get-themes-daily-routines-docs", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag(TAG) + .description("테마 리스트 별 데일리 루틴 리스트 조회") + .queryParameters( + parameterWithName("themes").description("조회할 테마 id 정보") + ) + .requestFields() + .responseFields( + fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"), + fieldWithPath("message").type(STRING).description("응답 메시지"), + fieldWithPath("data").type(OBJECT).description("응답 데이터"), + fieldWithPath("data.routines").type(ARRAY).description("루틴 정보 리스트"), + fieldWithPath("data.routines[].routineId").type(NUMBER).description("루틴 id"), + fieldWithPath("data.routines[].content").type(STRING).description("테마 내용") + ) + .build()))) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("테마 별 데일리 루틴 리스트 조회 성공") + void success_getDailyRoutinesByTheme() throws Exception { + // given + long themeId = 1L; + DailyRoutinesByThemeResponse routines = DailyRoutineFixture.createDailyRoutinesByThemeResponseDTO(); + ResponseEntity response = ResponseEntity.ok(Response.success("루틴 조회 성공", routines)); + + // when + when(controller.getRoutinesByTheme(principal, themeId)).thenReturn(response); + + // then + mockMvc + .perform( + RestDocumentationRequestBuilders.get(DEFAULT_URL + "/theme/{themeId}", 1L) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .principal(principal)) + .andDo( + MockMvcRestDocumentation.document( + "get-theme-daily-routines-docs", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag(TAG) + .description("테마 별 데일리 루틴 리스트 조회") + .pathParameters( + parameterWithName("themeId").description("테마 id") + ) + .requestFields() + .responseFields( + fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"), + fieldWithPath("message").type(STRING).description("응답 메시지"), + fieldWithPath("data").type(OBJECT).description("응답 데이터"), + fieldWithPath("data.backgroundImageUrl").type(STRING).description("테마 배경 이미지 url"), + fieldWithPath("data.routines").type(ARRAY).description("루틴 정보 리스트"), + fieldWithPath("data.routines[].routineId").type(NUMBER).description("루틴 id"), + fieldWithPath("data.routines[].content").type(STRING).description("테마 내용") + ) + .build()))) + .andExpect(status().isOk()); + } +} \ No newline at end of file diff --git a/src/test/java/com/soptie/server/routine/controller/HappinessRoutineControllerTest.java b/src/test/java/com/soptie/server/routine/controller/HappinessRoutineControllerTest.java new file mode 100644 index 00000000..181a5e0f --- /dev/null +++ b/src/test/java/com/soptie/server/routine/controller/HappinessRoutineControllerTest.java @@ -0,0 +1,192 @@ +package com.soptie.server.routine.controller; + +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.soptie.server.base.BaseControllerTest; +import com.soptie.server.common.config.ValueConfig; +import com.soptie.server.common.dto.Response; +import com.soptie.server.routine.dto.HappinessRoutinesResponse; +import com.soptie.server.routine.dto.HappinessSubRoutinesResponse; +import com.soptie.server.routine.dto.HappinessThemesResponse; +import com.soptie.server.routine.fixture.HappinessRoutineFixture; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import java.util.List; + +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static com.soptie.server.routine.message.SuccessMessage.SUCCESS_GET_HAPPINESS_SUB_ROUTINES; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.JsonFieldType.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(HappinessRoutineController.class) +class HappinessRoutineControllerTest extends BaseControllerTest { + + @MockBean + HappinessRoutineController controller; + @MockBean + ValueConfig valueConfig; + + private final String DEFAULT_URL = "/api/v1/routines/happiness"; + private final String TAG = "HAPPINESS ROUTINE"; + + @Test + @DisplayName("행복 루틴 테마 리스트 조회 성공") + void success_getHappinessThemes() throws Exception { + // given + HappinessThemesResponse themes = HappinessRoutineFixture.createHappinessThemesResponseDTO(); + ResponseEntity response = ResponseEntity.ok(Response.success("테마 조회 성공", themes)); + + // when + when(controller.getHappinessThemes()).thenReturn(response); + + // then + mockMvc + .perform( + RestDocumentationRequestBuilders.get(DEFAULT_URL + "/themes") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andDo( + MockMvcRestDocumentation.document( + "get-happiness-themes-docs", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag(TAG) + .description("행복 루틴 테마 리스트 조회") + .requestFields() + .responseFields( + fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"), + fieldWithPath("message").type(STRING).description("응답 메시지"), + fieldWithPath("data").type(OBJECT).description("응답 데이터"), + fieldWithPath("data.themes").type(ARRAY).description("테마 정보 리스트"), + fieldWithPath("data.themes[].themeId").type(NUMBER).description("테마 id"), + fieldWithPath("data.themes[].name").type(STRING).description("테마 이름") + ) + .build()))) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("테마 별 행복 루틴 리스트 조회 성공") + void success_getHappinessRoutinesByTheme() throws Exception { + HappinessRoutinesResponse routines = HappinessRoutineFixture.createHappinessRoutinesResponseDTO(); + ResponseEntity response = ResponseEntity.ok(Response.success("루틴 조회 성공", routines)); + + MultiValueMap queries = new LinkedMultiValueMap<>(); + String themes = "1"; + queries.add("themeId", themes); + + when(controller.getHappinessRoutinesByThemes(anyLong())).thenReturn(response); + + mockMvc + .perform( + RestDocumentationRequestBuilders.get(DEFAULT_URL) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .params(queries)) + .andDo( + MockMvcRestDocumentation.document( + "get-theme-happiness-routines-docs", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag(TAG) + .description("테마 별 행복 루틴 리스트 조회") + .queryParameters( + parameterWithName("themeId").description("조회할 테마 id") + ) + .requestFields() + .responseFields( + fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"), + fieldWithPath("message").type(STRING).description("응답 메시지"), + fieldWithPath("data").type(OBJECT).description("응답 데이터"), + fieldWithPath("data.routines").type(ARRAY).description("루틴 정보 리스트"), + fieldWithPath("data.routines[].routineId").type(NUMBER).description("행복 루틴 id"), + fieldWithPath("data.routines[].name").type(STRING).description("행복 루틴 제목"), + fieldWithPath("data.routines[].nameColor").type(STRING).description("행복 루틴 제목 색상"), + fieldWithPath("data.routines[].title").type(STRING).description("행복 루틴 설명"), + fieldWithPath("data.routines[].iconImageUrl").type(STRING).description("이미지 url") + ) + .build()))) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("행복 루틴 별 서브 행복 루틴 리스트 조회 성공") + void success_getHappinessSubRoutinesByRoutineOfTheme() throws Exception { + // given + List subRoutines = List.of( + HappinessSubRoutinesResponse.HappinessSubRoutineResponse.builder() + .subRoutineId(1L) + .content("소프티 숙소") + .detailContent("어쩌구 저쩌구") + .timeTaken("아무때") + .place("소프티 만나러 가는 길") + .build()); + HappinessSubRoutinesResponse response = HappinessSubRoutinesResponse.builder() + .title("타이틀") + .name("테마 이름") + .nameColor("#999") + .iconImageUrl("https://...") + .contentImageUrl("https://...") + .subRoutines(subRoutines) + .build(); + ResponseEntity result = ResponseEntity.ok(Response.success(SUCCESS_GET_HAPPINESS_SUB_ROUTINES.getMessage(), response)); + + // when + when(controller.getHappinessSubRoutinesByRoutineOfTheme(anyLong())).thenReturn(result); + + // then + mockMvc + .perform( + RestDocumentationRequestBuilders.get(DEFAULT_URL + "/routine/{routineId}", 1L) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andDo( + MockMvcRestDocumentation.document( + "get-sub-happiness-routines-by-routine-of-theme-docs", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag(TAG) + .description("행복 루틴 별 서브 행복 루틴 리스트 조회") + .pathParameters( + parameterWithName("routineId").description("루틴 id") + ) + .requestFields() + .responseFields( + fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"), + fieldWithPath("message").type(STRING).description("응답 메시지"), + fieldWithPath("data").type(OBJECT).description("응답 데이터"), + fieldWithPath("data.title").type(STRING).description("행복 루틴 주제"), + fieldWithPath("data.name").type(STRING).description("테마 이름"), + fieldWithPath("data.nameColor").type(STRING).description("색깔"), + fieldWithPath("data.iconImageUrl").type(STRING).description("아이콘 이미지 url"), + fieldWithPath("data.contentImageUrl").type(STRING).description("카드 이미지 url"), + fieldWithPath("data.subRoutines").type(ARRAY).description("서브 루틴 정보 리스트"), + fieldWithPath("data.subRoutines[].subRoutineId").type(NUMBER).description("서브 루틴 id"), + fieldWithPath("data.subRoutines[].content").type(STRING).description("서브 루틴 내용"), + fieldWithPath("data.subRoutines[].detailContent").type(STRING).description("서브 루틴 세부 내용"), + fieldWithPath("data.subRoutines[].timeTaken").type(STRING).description("소요 시간"), + fieldWithPath("data.subRoutines[].place").type(STRING).description("장소") + ) + .build()))) + .andExpect(status().isOk()); + } +} \ No newline at end of file diff --git a/src/test/java/com/soptie/server/routine/fixture/DailyRoutineFixture.java b/src/test/java/com/soptie/server/routine/fixture/DailyRoutineFixture.java new file mode 100644 index 00000000..9bcf751d --- /dev/null +++ b/src/test/java/com/soptie/server/routine/fixture/DailyRoutineFixture.java @@ -0,0 +1,47 @@ +package com.soptie.server.routine.fixture; + +import java.util.List; +import java.util.stream.Stream; + +import com.soptie.server.routine.dto.DailyRoutineResponse; +import com.soptie.server.routine.dto.DailyRoutinesByThemeResponse; +import com.soptie.server.routine.dto.DailyRoutinesByThemesResponse; +import com.soptie.server.routine.dto.DailyThemesResponse; + +public class DailyRoutineFixture { + + public static DailyThemesResponse createDailyThemesResponseDTO() { + return new DailyThemesResponse(createDailyThemeResponses()); + } + + private static List createDailyThemeResponses() { + return Stream.iterate(1, i -> i + 1).limit(5) + .map(DailyRoutineFixture::createDailyThemeResponse).toList(); + } + + private static DailyThemesResponse.DailyThemeResponse createDailyThemeResponse(int i) { + return DailyThemesResponse.DailyThemeResponse.builder() + .themeId(i) + .name("theme name" + i) + .iconImageUrl("https://...") + .backgroundImageUrl("https://...") + .build(); + } + + public static DailyRoutinesByThemesResponse createDailyRoutinesByThemesResponseDTO() { + return new DailyRoutinesByThemesResponse(createDailyRoutineResponses()); + } + + public static DailyRoutinesByThemeResponse createDailyRoutinesByThemeResponseDTO() { + return new DailyRoutinesByThemeResponse("https://...", createDailyRoutineResponses()); + } + + private static List createDailyRoutineResponses() { + return Stream.iterate(1, i -> i + 1).limit(5) + .map(DailyRoutineFixture::createDailyRoutineResponse).toList(); + } + + private static DailyRoutineResponse createDailyRoutineResponse(int i) { + return new DailyRoutineResponse((long)i, "routine content" + i); + } +} diff --git a/src/test/java/com/soptie/server/routine/fixture/HappinessRoutineFixture.java b/src/test/java/com/soptie/server/routine/fixture/HappinessRoutineFixture.java new file mode 100644 index 00000000..5ef9b5b0 --- /dev/null +++ b/src/test/java/com/soptie/server/routine/fixture/HappinessRoutineFixture.java @@ -0,0 +1,44 @@ +package com.soptie.server.routine.fixture; + +import com.soptie.server.routine.dto.HappinessRoutinesResponse; +import com.soptie.server.routine.dto.HappinessThemesResponse; + +import java.util.List; +import java.util.stream.Stream; + +public class HappinessRoutineFixture { + + private static final String NAME = "Happiness routine Theme"; + private static final String NAME_COLOR = "Happiness routine Theme"; + private static final String TITLE = "Happiness routine Theme"; + private static final String ICON_IMAGE_URL = "icon_image_url"; + + + public static HappinessThemesResponse createHappinessThemesResponseDTO() { + List routines = createHappinessThemeResponses(); + return new HappinessThemesResponse(routines); + } + + private static List createHappinessThemeResponses() { + return Stream.iterate(1, i -> i + 1).limit(5) + .map(HappinessRoutineFixture::createHappinessThemeResponse).toList(); + } + + private static HappinessThemesResponse.HappinessThemeResponse createHappinessThemeResponse(int i) { + return new HappinessThemesResponse.HappinessThemeResponse((long)i, NAME + i); + } + + public static HappinessRoutinesResponse createHappinessRoutinesResponseDTO() { + List routines = createHappinessRoutineResponses(); + return new HappinessRoutinesResponse(routines); + } + + private static List createHappinessRoutineResponses() { + return Stream.iterate(1, i -> i + 1).limit(5) + .map(HappinessRoutineFixture::createHappinessRoutineResponse).toList(); + } + + private static HappinessRoutinesResponse.HappinessRoutineResponse createHappinessRoutineResponse(int i) { + return new HappinessRoutinesResponse.HappinessRoutineResponse((long)i, NAME + i, NAME_COLOR + i, TITLE + i, ICON_IMAGE_URL + i); + } +} diff --git a/src/test/java/com/soptie/server/routine/repository/daily/routine/DailyRoutineRepositoryTest.java b/src/test/java/com/soptie/server/routine/repository/daily/routine/DailyRoutineRepositoryTest.java new file mode 100644 index 00000000..1200ef8f --- /dev/null +++ b/src/test/java/com/soptie/server/routine/repository/daily/routine/DailyRoutineRepositoryTest.java @@ -0,0 +1,109 @@ +package com.soptie.server.routine.repository.daily.routine; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; + +import com.soptie.server.base.BaseRepositoryTest; +import com.soptie.server.common.config.ValueConfig; +import com.soptie.server.routine.entity.daily.DailyRoutine; +import com.soptie.server.routine.entity.daily.DailyTheme; +import com.soptie.server.routine.entity.daily.RoutineImage; +import com.soptie.server.routine.repository.daily.theme.DailyThemeRepository; + +import static org.hamcrest.MatcherAssert.*; +import static org.hamcrest.CoreMatchers.*; + +class DailyRoutineRepositoryTest extends BaseRepositoryTest { + + @Autowired + private DailyRoutineRepository dailyRoutineRepository; + @Autowired + private DailyThemeRepository dailyThemeRepository; + + @MockBean + ValueConfig valueConfig; + + private final String ROUTINE_CONTENT = "오늘의 음악 선곡하기"; + private final DailyTheme THEME = getTheme(); + + @BeforeEach + void init() { + dailyThemeRepository.save(THEME); + } + + @DisplayName("데일리 루틴 저장") + @Test + void success_createRoutine() { + // given + int i = 0; + DailyRoutine routine = getRoutine(i); + + // when + DailyRoutine savedRoutine = dailyRoutineRepository.save(routine); + + // then + assertThat(savedRoutine.getContent(), is(equalTo(ROUTINE_CONTENT + i))); + assertThat(savedRoutine.getTheme(), is(equalTo(THEME))); + } + + @DisplayName("데일리 루틴 조회") + @Test + void success_getRoutine() { + // given + int i = 0; + DailyRoutine savedRoutine = dailyRoutineRepository.save(getRoutine(i)); + + // when + Optional routine = dailyRoutineRepository.findById(savedRoutine.getId()); + + // then + assertThat(routine.isPresent(), is(equalTo(true))); + assertThat(routine.get(), is(equalTo(savedRoutine))); + assertThat(routine.get().getId(), is(equalTo(savedRoutine.getId()))); + assertThat(routine.get().getContent(), is(equalTo(savedRoutine.getContent()))); + assertThat(routine.get().getTheme(), is(equalTo(savedRoutine.getTheme()))); + } + + @DisplayName("테마별 데일리 루틴 목록 조회") + @Test + void success_getRoutinesByTheme() { + // given + int size = 5; + Stream.iterate(1, i -> i + 1).limit(size) + .forEach(i -> dailyRoutineRepository.save(getRoutine(i))); + + // when + List result = dailyRoutineRepository.findAllByTheme(THEME); + + // then + assertThat(result.size(), is(equalTo(size))); + } + + private DailyRoutine getRoutine(int i) { + return DailyRoutine.builder() + .content(ROUTINE_CONTENT + i) + .theme(THEME) + .build(); + } + + private DailyTheme getTheme() { + return DailyTheme.builder() + .name("소중한 나") + .imageInfo(getImageInfo()) + .build(); + } + + private RoutineImage getImageInfo() { + return RoutineImage.builder() + .iconImageUrl("https://...") + .backgroundImageUrl("https://...") + .build(); + } +} \ No newline at end of file diff --git a/src/test/java/com/soptie/server/routine/service/DailyRoutineServiceTest.java b/src/test/java/com/soptie/server/routine/service/DailyRoutineServiceTest.java new file mode 100644 index 00000000..38965f78 --- /dev/null +++ b/src/test/java/com/soptie/server/routine/service/DailyRoutineServiceTest.java @@ -0,0 +1,100 @@ +package com.soptie.server.routine.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import com.soptie.server.base.BaseServiceTest; +import com.soptie.server.member.entity.Member; +import com.soptie.server.member.entity.SocialType; +import com.soptie.server.member.repository.MemberRepository; +import com.soptie.server.routine.dto.DailyRoutinesByThemeResponse; +import com.soptie.server.routine.entity.daily.DailyRoutine; +import com.soptie.server.routine.entity.daily.DailyTheme; +import com.soptie.server.routine.entity.daily.RoutineImage; +import com.soptie.server.routine.repository.daily.routine.DailyRoutineRepository; +import com.soptie.server.routine.repository.daily.theme.DailyThemeRepository; + +import static org.hamcrest.MatcherAssert.*; +import static org.mockito.Mockito.*; +import static org.hamcrest.CoreMatchers.*; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +class DailyRoutineServiceTest extends BaseServiceTest { + + @InjectMocks + private DailyRoutineServiceImpl dailyRoutineService; + + @Mock + private DailyRoutineRepository dailyRoutineRepository; + @Mock + private DailyThemeRepository dailyThemeRepository; + @Mock + private MemberRepository memberRepository; + + private final Member MEMBER = member(); + private final DailyTheme THEME = theme(); + private final int LIST_SIZE = 5; + private final String ROUTINE_CONTENT = "오늘의 음악 선곡하기"; + + @DisplayName("테마별 데일리 루틴 조회") + @Test + void success_getRoutinesByTheme() { + // given + long memberId = 0L; + long themeId = 1L; + List routines = routineList(); + + when(memberRepository.findById(memberId)).thenReturn(Optional.of(MEMBER)); + when(dailyThemeRepository.findById(themeId)).thenReturn(Optional.of(THEME)); + when(dailyRoutineRepository.findAllByTheme(THEME)).thenReturn(routines); + + // when + DailyRoutinesByThemeResponse response = dailyRoutineService.getRoutinesByTheme(memberId, themeId); + + // then + assertThat(response.routines().size(), is(equalTo(LIST_SIZE))); + assertThat(response.routines().get(0).content(), is(equalTo(ROUTINE_CONTENT + 1))); + + // verify + verify(dailyThemeRepository, times(1)).findById(themeId); + verify(dailyRoutineRepository, times(1)).findAllByTheme(THEME); + } + + private List routineList() { + return Stream.iterate(1, i -> i + 1).limit(LIST_SIZE) + .map(this::routine).toList(); + } + + private DailyRoutine routine(int i) { + return DailyRoutine.builder() + .content(ROUTINE_CONTENT + i) + .theme(THEME) + .build(); + } + + private Member member() { + return Member.builder() + .socialType(SocialType.KAKAO) + .socialId("socialId") + .build(); + } + + private DailyTheme theme() { + return DailyTheme.builder() + .name("소중한 나") + .imageInfo(imageInfo()) + .build(); + } + + private RoutineImage imageInfo() { + return RoutineImage.builder() + .iconImageUrl("https://...") + .backgroundImageUrl("https://...") + .build(); + } +} \ No newline at end of file diff --git a/src/test/java/com/soptie/server/test/TestControllerTest.java b/src/test/java/com/soptie/server/test/TestControllerTest.java new file mode 100644 index 00000000..437752d6 --- /dev/null +++ b/src/test/java/com/soptie/server/test/TestControllerTest.java @@ -0,0 +1,62 @@ +package com.soptie.server.test; + +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static org.mockito.Mockito.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; + +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.soptie.server.base.BaseControllerTest; +import com.soptie.server.common.config.ValueConfig; + +@WebMvcTest(TestController.class) +public class TestControllerTest extends BaseControllerTest { + + @MockBean + TestController testController; + @MockBean + ValueConfig valueConfig; + + private final String DEFAULT_URL = "/api/v1/test"; + private final String TAG = "TEST"; + + @Test + @DisplayName("서버 연결 테스트") + void success_test() throws Exception { + // given + ResponseEntity response = ResponseEntity.ok("Success to server connect."); + + //when + when(testController.test()).thenReturn(response); + + //then + mockMvc + .perform( + RestDocumentationRequestBuilders.get(DEFAULT_URL) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andDo( + MockMvcRestDocumentation.document( + "test-docs", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag(TAG) + .description("서버 연결 테스트") + .requestFields() + .responseFields() + .build()))) + .andExpect( + MockMvcResultMatchers.status().isOk()); + } +} diff --git a/src/test/java/com/soptie/server/version/controller/VersionControllerTest.java b/src/test/java/com/soptie/server/version/controller/VersionControllerTest.java new file mode 100644 index 00000000..dd29c53c --- /dev/null +++ b/src/test/java/com/soptie/server/version/controller/VersionControllerTest.java @@ -0,0 +1,89 @@ +package com.soptie.server.version.controller; + +import static com.epages.restdocs.apispec.ResourceDocumentation.*; +import static org.mockito.Mockito.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.JsonFieldType.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.security.Principal; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; + +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.soptie.server.base.BaseControllerTest; +import com.soptie.server.common.config.ValueConfig; +import com.soptie.server.common.dto.Response; +import com.soptie.server.version.dto.AppVersionResponse; + +@WebMvcTest(VersionController.class) +class VersionControllerTest extends BaseControllerTest { + + @MockBean + VersionController controller; + @MockBean + Principal principal; + @MockBean + ValueConfig valueConfig; + + private final String DEFAULT_URL = "/api/v1/versions"; + private final String TAG = "VERSION"; + + @Test + @DisplayName("클라이언트 앱 버전 정보 조회 성공") + void success_getClientAppVersion() throws Exception { + // given + AppVersionResponse versionInfo = AppVersionResponse.of( + "1.0.0", + "1.0.0", + "1.0.0", + "1.0.0", + "업데이트 안내", + "업데이트가 필요합니다."); + ResponseEntity response = ResponseEntity.ok(Response.success("버전 조회 성공", versionInfo)); + + // when + when(controller.getClientAppVersion()).thenReturn(response); + + // then + mockMvc + .perform( + RestDocumentationRequestBuilders.get(DEFAULT_URL + "/client/app") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andDo( + MockMvcRestDocumentation.document( + "get-client-app-version-docs", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag(TAG) + .description("앱 버전 정보 조회") + .requestFields() + .responseFields( + fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"), + fieldWithPath("message").type(STRING).description("응답 메시지"), + fieldWithPath("data").type(OBJECT).description("응답 데이터"), + fieldWithPath("data.iosVersion").type(OBJECT).description("iOS 버전 정보"), + fieldWithPath("data.iosVersion.appVersion").type(STRING).description("iOS 앱 버전"), + fieldWithPath("data.iosVersion.forceUpdateVersion").type(STRING).description("iOS 강제 업데이트 버전"), + fieldWithPath("data.androidVersion").type(OBJECT).description("안드로이드 버전 정보"), + fieldWithPath("data.androidVersion.appVersion").type(STRING).description("안드로이드 앱 버전"), + fieldWithPath("data.androidVersion.forceUpdateVersion").type(STRING).description("안드로이드 강제 업데이트 버전"), + fieldWithPath("data.notificationTitle").type(STRING).description("업데이트 알림 제목"), + fieldWithPath("data.notificationContent").type(STRING).description("업데이트 알림 내용") + ) + .build()))) + .andExpect(status().isOk()); + } + +} \ No newline at end of file