diff --git a/.github/workflows/Diareat-CICD.yml b/.github/workflows/Diareat-CICD.yml new file mode 100644 index 0000000..56f467f --- /dev/null +++ b/.github/workflows/Diareat-CICD.yml @@ -0,0 +1,90 @@ +name: Diareat CI/CD + +on: + push: + branches: [ "master" ] + paths-ignore: + - 'k8s/**' + +permissions: + contents: read + +jobs: + CI: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up JDK 11 + uses: actions/setup-java@v3 + with: + java-version: '11' + distribution: 'temurin' + + - name: Build with Gradle + uses: gradle/gradle-build-action@bd5760595778326ba7f1441bcf7e88b49de61a25 # v2.6.0 + env: + DB_ENDPOINT: ${{ secrets.DB_ENDPOINT }} + DB_USERNAME: ${{ secrets.DB_USERNAME }} + DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + with: + arguments: build + + - name: Docker login + uses: docker/login-action@v3.0.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + logout: true + + - name: Get version + id: image + run: | + VERSION=$(echo ${{ github.sha }} | cut -c1-8) + echo VERSION=$VERSION + echo "::set-output name=version::$VERSION" + + - name: Build and push to DockerHub + run: | + docker build -t synoti21/${{ secrets.PROJECT_NAME }}:${{ steps.image.outputs.version }} . + docker push synoti21/${{ secrets.PROJECT_NAME }}:${{ steps.image.outputs.version }} + + + CD: + runs-on: ubuntu-latest + needs: CI + steps: + - name: Get version + id: image + run: | + VERSION=$(echo ${{ github.sha }} | cut -c1-8) + echo VERSION=$VERSION + echo "::set-output name=version::$VERSION" + + - name: Setup Kustomize + uses: imranismail/setup-kustomize@v2.1.0-rc + + + - name: Checkout kustomize repository + uses: actions/checkout@v3 + with: + repository: CAUSOLDOUTMEN/Diareat_backend + ref: master + token: ${{ secrets.ACCESS_TOKEN }} + path: Diareat_backend + + + - name: Update Kubernetes resources + run: | + cd Diareat_backend/k8s/ + kustomize edit set image synoti21/${{ secrets.PROJECT_NAME }}=synoti21/${{ secrets.PROJECT_NAME }}:${{ steps.image.outputs.version }} + kustomize build . + + - name: Commit and push the updated manifest + run: | + cd Diareat_backend + git config --global user.name 'github-actions' + git config --global user.email 'github-actions@github.com' + git commit -am ":construction_worker: chore: Update deployment to ${{ github.sha }}" + git push diff --git a/.github/workflows/diareat-Build.yml b/.github/workflows/diareat-Build.yml new file mode 100644 index 0000000..8128bdb --- /dev/null +++ b/.github/workflows/diareat-Build.yml @@ -0,0 +1,33 @@ +name: Diareat Build Test + +on: + pull_request: + branches: [ "master" ] + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up JDK 11 + uses: actions/setup-java@v3 + with: + java-version: '11' + distribution: 'temurin' + + - name: Build with Gradle + uses: gradle/gradle-build-action@bd5760595778326ba7f1441bcf7e88b49de61a25 # v2.6.0 + env: + DB_ENDPOINT: ${{ secrets.DB_ENDPOINT }} + DB_USERNAME: ${{ secrets.DB_USERNAME }} + DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + with: + arguments: build + diff --git a/.gitignore b/.gitignore index c2065bc..e48b6be 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,4 @@ out/ /.nb-gradle/ ### VS Code ### -.vscode/ +.vscode/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4173c9b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM gradle:8.2.1-jdk11 AS build + +COPY --chown=gradle:gradle . /home/gradle/src +WORKDIR /home/gradle/src +RUN gradle build --no-daemon -x test + +FROM openjdk:11-jre-slim + +EXPOSE 8080 +COPY --from=build /home/gradle/src/build/libs/*.jar /app/app.jar + +ENTRYPOINT ["java", "-Djava.security.egd=file:/dev/./urandom", "-jar", "/app/app.jar"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0b0f16b --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 CAUSOLDOUTMEN + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e6649b0 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# 프로젝트명 +다이어릿 - Diareat (2023.09 ~ 2023.12) + +# Diareat, 사각지대 없는 식습관 관리를 위해! +image + +<다이어릿>은 음식을 촬영하면 칼로리를 대략적으로 계산해주는 기존 서비스에서 영감을 받아, 가공식품의 영양성분표를 촬영하면 칼로리를 추출해주는 OCR 기능을 추가로 결합해 식습관 기록의 사각지대를 해소하는 데 기여하는 Food Capture 서비스입니다. +## Camera + +https://github.com/CAUSOLDOUTMEN/Diareat_backend/assets/53044069/f36dba64-3022-4b1b-b811-d9203fb987b5 + + +- 음식이나 영양성분표를 촬영하면 일정 시간 후 영양성분을 추출하실 수 있습니다. + - 실제 음식의 경우 두잉랩의 foodlens에서 제공하는 외부 API를 사용합니다. + - 영양성분표의 경우 Pororo OCR을 기반으로 자체 구축한 OCR 서버를 활용합니다. +## Diary +image + + +- [오늘의 영양] 화면에서 오늘 촬영한 음식들에 대한 영양분이 실시간으로 결산됩니다. +- [다이어리] 화면에서 오늘 촬영한 음식들에 대한 정보를 확인할 수 있습니다. +- [즐겨찾기] 기능을 통해 자주 먹는 음식을 등록하여 재촬영 없이 편리한 다이어리 추가가 가능합니다. +- [프로필] 화면에서 개인정보 및 목표로 설정된 기준섭취량을 확인하고 직접 수정하실 수 있습니다. + +## Analysis +image + +- [일기 분석] 화면에서 최근 7일 및 4주 간의 영양소별 섭취 현황을 그래프로 제공합니다. +- [자세히 보기]를 통해 이번 주의 구체적인 식습관 점수 현황, 최근 Best, Worst 음식들을 확인하실 수 있습니다. +- [주간 랭킹] 화면에서 검색 기능을 통해 특정 유저를 팔로우 혹은 팔로우 취소할 수 있고, 자신이 팔로우한 유저들의 주간 식습관 점수를 확인하실 수 있습니다. +- 친구들과 함께 앱을 사용하며 식습관 개선 목표를 달성하기 위해 서로 경쟁해보세요! + +# Project Information + +## Members +![image](https://github.com/CAUSOLDOUTMEN/Diareat_backend/assets/53044069/f85d298e-9c12-417b-b2b6-bdbfd5b86bbb) + +## Tech Stack + +### Frontend + +- Android +- Kotlin + +### Backend + +- Java 11 +- Springboot +- Redis +- MySQL + +### OCR Server + +- Python (Pororo OCR) +- OpenCV +- FastAPI +- AWS EC2 + +### Infra & CI/CD + +- Kubernetes +- Argo CD +- Kustomization +- Github actions +- Docker + +## Project **Architecture** +![image](https://github.com/CAUSOLDOUTMEN/Diareat_backend/assets/53044069/21f5deb4-8dce-4cc9-a148-6a78e8370c4e) diff --git a/build.gradle b/build.gradle index e1b0b09..9fc03f3 100644 --- a/build.gradle +++ b/build.gradle @@ -22,8 +22,8 @@ repositories { } dependencies { - // implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - // implementation 'org.springframework.boot:spring-boot-starter-jdbc' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-jdbc' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' @@ -31,10 +31,30 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-websocket' implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5' compileOnly 'org.projectlombok:lombok' - // runtimeOnly 'com.mysql:mysql-connector-j' + 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' + + // Swagger dependency + implementation 'io.springfox:springfox-boot-starter:3.0.0' + implementation 'io.springfox:springfox-swagger-ui:3.0.0' + + // Webclient dependency + implementation platform('org.springframework.boot:spring-boot-dependencies:2.7.15') + implementation 'org.springframework.boot:spring-boot-starter-webflux' + + implementation 'io.netty:netty-resolver-dns-native-macos:4.1.68.Final:osx-aarch_64' + + // Jwt dependency + implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.2.2' // Spring Boot MyBatis + implementation "io.jsonwebtoken:jjwt:0.9.1" + + // Redis dependency + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // Validation dependency + implementation 'org.springframework.boot:spring-boot-starter-validation' } tasks.named('test') { diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml new file mode 100644 index 0000000..f1f514b --- /dev/null +++ b/k8s/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- manifest.yaml +images: +- name: synoti21/diareat-backend + newName: synoti21/diareat-backend + newTag: 19305c83 diff --git a/k8s/manifest.yaml b/k8s/manifest.yaml new file mode 100644 index 0000000..b33742d --- /dev/null +++ b/k8s/manifest.yaml @@ -0,0 +1,94 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: diareat-deployment + namespace: diareat +spec: + revisionHistoryLimit: 3 + selector: + matchLabels: + app: diareat + template: + metadata: + labels: + app: diareat + spec: + containers: + - name: diareat + image: synoti21/diareat-backend:latest + imagePullPolicy: IfNotPresent + envFrom: + - secretRef: + name: diareat-secret + resources: + requests: + memory: "2G" + cpu: 0.2 + limits: + memory: "4G" + cpu: 1 + ports: + - containerPort: 8080 + readinessProbe: + httpGet: + path: /swagger-ui/index.html + port: 8080 + initialDelaySeconds: 40 + periodSeconds: 5 + failureThreshold: 24 +--- +apiVersion: v1 +kind: Service +metadata: + name: diareat-svc + namespace: diareat +spec: + selector: + app: diareat + ports: + - protocol: TCP + port: 2938 + targetPort: 8080 +--- +apiVersion: traefik.containo.us/v1alpha1 +kind: IngressRoute +metadata: + name: diareat-route + namespace: diareat +spec: + entryPoints: + - websecure + routes: + - match: Host(`diareat.thisiswandol.com`) + kind: Rule + services: + - name: diareat-svc + port: 2938 + tls: + certResolver: myresolver +--- +apiVersion: autoscaling/v2beta2 +kind: HorizontalPodAutoscaler +metadata: + name: diareat-hpa + namespace: diareat +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: diareat-deployment + minReplicas: 1 + maxReplicas: 3 + metrics: + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 50 + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 50 diff --git a/src/main/java/com/diareat/diareat/DiareatApplication.java b/src/main/java/com/diareat/diareat/DiareatApplication.java index 87fdffa..bb6db32 100644 --- a/src/main/java/com/diareat/diareat/DiareatApplication.java +++ b/src/main/java/com/diareat/diareat/DiareatApplication.java @@ -2,7 +2,13 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import javax.annotation.PostConstruct; +import java.util.TimeZone; + +@EnableCaching @SpringBootApplication public class DiareatApplication { @@ -10,4 +16,9 @@ public static void main(String[] args) { SpringApplication.run(DiareatApplication.class, args); } + @PostConstruct + void started(){ + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); + } + } diff --git a/src/main/java/com/diareat/diareat/auth/component/JwtAuthFilter.java b/src/main/java/com/diareat/diareat/auth/component/JwtAuthFilter.java new file mode 100644 index 0000000..e0f61e5 --- /dev/null +++ b/src/main/java/com/diareat/diareat/auth/component/JwtAuthFilter.java @@ -0,0 +1,36 @@ +package com.diareat.diareat.auth.component; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.GenericFilterBean; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import java.io.IOException; + +@RequiredArgsConstructor +public class JwtAuthFilter extends GenericFilterBean { + + private final JwtTokenProvider jwtTokenProvider; + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + // 헤더에서 토큰 받아오기 + String token = jwtTokenProvider.resolveToken((HttpServletRequest) request); + + // 토큰이 유효하다면 + if (token != null && jwtTokenProvider.validateAccessToken(token)) { + // 토큰으로부터 유저 정보를 받아 + Authentication authentication = jwtTokenProvider.getAuthentication(token); + // SecurityContext 에 객체 저장 + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + // 다음 Filter 실행 + chain.doFilter(request, response); + } +} diff --git a/src/main/java/com/diareat/diareat/auth/component/JwtTokenProvider.java b/src/main/java/com/diareat/diareat/auth/component/JwtTokenProvider.java new file mode 100644 index 0000000..a446dcf --- /dev/null +++ b/src/main/java/com/diareat/diareat/auth/component/JwtTokenProvider.java @@ -0,0 +1,105 @@ +package com.diareat.diareat.auth.component; + +import com.diareat.diareat.util.api.ResponseCode; +import com.diareat.diareat.util.exception.ValidException; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.stereotype.Component; + +import javax.annotation.PostConstruct; +import javax.servlet.http.HttpServletRequest; +import java.util.Base64; +import java.util.Date; +import java.util.concurrent.TimeUnit; + +@RequiredArgsConstructor +@Component +public class JwtTokenProvider { + + @Value("${jwt.secret}") + private String secretKey; + + private final UserDetailsService userDetailsService; + + private final RedisTemplate redisTemplate; + + // 객체 초기화, secretKey를 Base64로 인코딩 + @PostConstruct + protected void init() { + secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes()); + } + + // 토큰 생성 + public String createAccessToken(String userPk) { + Claims claims = Jwts.claims().setSubject(userPk); // JWT payload 에 저장되는 정보단위 + Date now = new Date(); + return Jwts.builder() + .setClaims(claims) // 정보 저장 + .setIssuedAt(now) // 토큰 발행 시간 정보 + .setExpiration(new Date(now.getTime() + (60 * 60 * 1000L))) // 토큰 유효시각 설정 (1시간) + .signWith(SignatureAlgorithm.HS256, secretKey) // 암호화 알고리즘과, secret 값 + .compact(); + } + + public String createRefreshToken(String userPk) { + Claims claims = Jwts.claims().setSubject(userPk); // JWT payload 에 저장되는 정보단위 + Date now = new Date(); + String refreshToken = Jwts.builder() + .setClaims(claims) // 정보 저장 + .setIssuedAt(now) // 토큰 발행 시간 정보 + .setExpiration(new Date(now.getTime() + (7 * 24 * 60 * 60 * 1000L))) // 토큰 유효시각 설정 (1주일) + .signWith(SignatureAlgorithm.HS256, secretKey) + .compact(); + + redisTemplate.opsForValue().set( + userPk, + refreshToken, + 7 * 24 * 60 * 60 * 1000L, + TimeUnit.MILLISECONDS + ); + + return refreshToken; + } + + // 인증 정보 조회 + public Authentication getAuthentication(String token) { + UserDetails userDetails = userDetailsService.loadUserByUsername(String.valueOf(this.getUserPk(token))); + return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()); + } + + // 토큰에서 회원 정보 추출 + public Long getUserPk(String token) { + return Long.parseLong(Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject()); + } + + // 토큰 유효성, 만료일자 확인 + public boolean validateAccessToken(String jwtToken) { + try { + Jws claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken); + return !claims.getBody().getExpiration().before(new Date()); + } catch (Exception e) { + return false; + } + } + + public void validateRefreshToken(Long userPK, String refreshToken) { + String redisRefreshToken = redisTemplate.opsForValue().get(String.valueOf(userPK)); + if (redisRefreshToken == null || !redisRefreshToken.equals(refreshToken)) { + throw new ValidException(ResponseCode.REFRESH_TOKEN_VALIDATION_FAILURE); + } + } + + // Request의 Header에서 token 값 가져오기 + public String resolveToken(HttpServletRequest request) { + return request.getHeader("accessToken"); + } +} diff --git a/src/main/java/com/diareat/diareat/auth/component/KakaoUserInfo.java b/src/main/java/com/diareat/diareat/auth/component/KakaoUserInfo.java new file mode 100644 index 0000000..5727ffc --- /dev/null +++ b/src/main/java/com/diareat/diareat/auth/component/KakaoUserInfo.java @@ -0,0 +1,24 @@ +package com.diareat.diareat.auth.component; + +import com.diareat.diareat.auth.dto.KakaoUserInfoResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Flux; + +@RequiredArgsConstructor +@Component +public class KakaoUserInfo { + + private final WebClient webClient; + private static final String USER_INFO_URI = "https://kapi.kakao.com/v2/user/me"; + + public KakaoUserInfoResponse getUserInfo(String token) { + Flux response = webClient.get() + .uri(USER_INFO_URI) + .header("Authorization", "Bearer " + token) + .retrieve() + .bodyToFlux(KakaoUserInfoResponse.class); + return response.blockFirst(); + } +} diff --git a/src/main/java/com/diareat/diareat/auth/controller/AuthController.java b/src/main/java/com/diareat/diareat/auth/controller/AuthController.java new file mode 100644 index 0000000..fb2f744 --- /dev/null +++ b/src/main/java/com/diareat/diareat/auth/controller/AuthController.java @@ -0,0 +1,78 @@ +package com.diareat.diareat.auth.controller; + +import com.diareat.diareat.auth.component.JwtTokenProvider; +import com.diareat.diareat.auth.dto.ResponseJwtDto; +import com.diareat.diareat.auth.service.KakaoAuthService; +import com.diareat.diareat.user.dto.request.JoinUserDto; +import com.diareat.diareat.user.service.UserService; +import com.diareat.diareat.util.api.ApiResponse; +import com.diareat.diareat.util.api.ResponseCode; +import io.swagger.annotations.Api; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; + +@Api(tags = "2. Auth") +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/auth") +public class AuthController { + + private final UserService userService; + private final KakaoAuthService kakaoAuthService; + private final JwtTokenProvider jwtTokenProvider; + + // 카카오 로그인을 위해 회원가입 여부 확인, 이미 회원이면 Jwt 토큰 발급 + @Operation(summary = "[로그인] 카카오로그인 및 토큰 발급", description = "카카오 로그인을 위해 회원가입 여부를 확인하고, 이미 회원이면 id와 Jwt 토큰을 발급합니다.") + @PostMapping("/login") + public ApiResponse authCheck(@RequestHeader String accessToken) { + Long userId = kakaoAuthService.isSignedUp(accessToken); // 유저 고유번호 추출 + + ResponseJwtDto responseJwtDto = (userId == null) ? null : ResponseJwtDto.builder() + .userId(userId) + .accessToken(jwtTokenProvider.createAccessToken(userId.toString())) + .refreshToken(jwtTokenProvider.createRefreshToken(userId.toString())) + .build(); + + return ApiResponse.success(responseJwtDto, ResponseCode.USER_LOGIN_SUCCESS.getMessage()); + } + + // 회원가입 (성공 시 Jwt 토큰 발급) + @Operation(summary = "[회원가입] 회원가입 및 토큰 발급", description = "신규 회원가입을 처리하고, 회원가입 성공 시 id와 Jwt 토큰을 발급합니다.") + @PostMapping("/join") + public ApiResponse saveUser(@Valid @RequestBody JoinUserDto joinUserDto) { + Long userId = userService.saveUser(kakaoAuthService.createUserDto(joinUserDto)); + + ResponseJwtDto responseJwtDto = (userId == null) ? null : ResponseJwtDto.builder() + .userId(userId) + .accessToken(jwtTokenProvider.createAccessToken(userId.toString())) + .refreshToken(jwtTokenProvider.createRefreshToken(userId.toString())) + .build(); + + return ApiResponse.success(responseJwtDto, ResponseCode.USER_CREATE_SUCCESS.getMessage()); + } + + // 토큰 검증 (Jwt 토큰을 서버에 전송하여, 서버가 유효한 토큰인지 확인하고 True 혹은 예외 반환) + @Operation(summary = "[토큰 검증] 토큰 검증", description = "클라이언트가 가지고 있던 Jwt 토큰을 서버에 전송하여, 서버가 유효한 토큰인지 확인하고 OK 혹은 예외를 반환합니다.") + @GetMapping("/token") + public ApiResponse tokenCheck(@RequestHeader String accessToken) { + return ApiResponse.success(jwtTokenProvider.validateAccessToken(accessToken), ResponseCode.TOKEN_CHECK_SUCCESS.getMessage()); + } + + @Operation(summary = "[토큰 재발급] 토큰 재발급", description = "클라이언트가 가지고 있던 Refresh 토큰을 서버에 전송하여, 서버가 유효한 토큰인지 확인하고 OK 혹은 예외를 반환합니다.") + @PostMapping("/reissue") + public ApiResponse reissueToken(@RequestHeader String refreshToken) { + Long userId = jwtTokenProvider.getUserPk(refreshToken); + jwtTokenProvider.validateRefreshToken(userId, refreshToken); + + ResponseJwtDto responseJwtDto = (userId == null) ? null : ResponseJwtDto.builder() + .userId(userId) + .accessToken(jwtTokenProvider.createAccessToken(userId.toString())) + .refreshToken(jwtTokenProvider.createRefreshToken(userId.toString())) + .build(); + + return ApiResponse.success(responseJwtDto, ResponseCode.TOKEN_REISSUE_SUCCESS.getMessage()); + } +} diff --git a/src/main/java/com/diareat/diareat/auth/dto/KakaoAccount.java b/src/main/java/com/diareat/diareat/auth/dto/KakaoAccount.java new file mode 100644 index 0000000..3f0a804 --- /dev/null +++ b/src/main/java/com/diareat/diareat/auth/dto/KakaoAccount.java @@ -0,0 +1,11 @@ +package com.diareat.diareat.auth.dto; + +import lombok.Getter; + +@Getter +public class KakaoAccount { + + private Boolean profile_nickname_needs_agreement; + private Boolean profile_image_needs_agreement; + private KakaoProfile profile; +} diff --git a/src/main/java/com/diareat/diareat/auth/dto/KakaoProfile.java b/src/main/java/com/diareat/diareat/auth/dto/KakaoProfile.java new file mode 100644 index 0000000..ea6e2c8 --- /dev/null +++ b/src/main/java/com/diareat/diareat/auth/dto/KakaoProfile.java @@ -0,0 +1,12 @@ +package com.diareat.diareat.auth.dto; + +import lombok.Getter; + +@Getter +public class KakaoProfile { + + private String nickname; + private String thumbnail_image_url; + private String profile_image_url; + private Boolean is_default_image; +} diff --git a/src/main/java/com/diareat/diareat/auth/dto/KakaoProperties.java b/src/main/java/com/diareat/diareat/auth/dto/KakaoProperties.java new file mode 100644 index 0000000..4b11027 --- /dev/null +++ b/src/main/java/com/diareat/diareat/auth/dto/KakaoProperties.java @@ -0,0 +1,11 @@ +package com.diareat.diareat.auth.dto; + +import lombok.Getter; + +@Getter +public class KakaoProperties { + + private String nickname; + private String profile_image; + private String thumbnail_image; +} diff --git a/src/main/java/com/diareat/diareat/auth/dto/KakaoUserInfoResponse.java b/src/main/java/com/diareat/diareat/auth/dto/KakaoUserInfoResponse.java new file mode 100644 index 0000000..039c62b --- /dev/null +++ b/src/main/java/com/diareat/diareat/auth/dto/KakaoUserInfoResponse.java @@ -0,0 +1,12 @@ +package com.diareat.diareat.auth.dto; + +import lombok.Getter; + +@Getter +public class KakaoUserInfoResponse { + + private Long id; + private String connected_at; + private KakaoProperties properties; + private KakaoAccount kakao_account; +} diff --git a/src/main/java/com/diareat/diareat/auth/dto/ResponseJwtDto.java b/src/main/java/com/diareat/diareat/auth/dto/ResponseJwtDto.java new file mode 100644 index 0000000..25d74d7 --- /dev/null +++ b/src/main/java/com/diareat/diareat/auth/dto/ResponseJwtDto.java @@ -0,0 +1,15 @@ +package com.diareat.diareat.auth.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +public class ResponseJwtDto { + + private Long userId; + private String accessToken; + private String refreshToken; +} diff --git a/src/main/java/com/diareat/diareat/auth/service/CustomUserDetailService.java b/src/main/java/com/diareat/diareat/auth/service/CustomUserDetailService.java new file mode 100644 index 0000000..fd5b14b --- /dev/null +++ b/src/main/java/com/diareat/diareat/auth/service/CustomUserDetailService.java @@ -0,0 +1,23 @@ +package com.diareat.diareat.auth.service; + +import com.diareat.diareat.user.repository.UserRepository; +import com.diareat.diareat.util.api.ResponseCode; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class CustomUserDetailService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String id) throws UsernameNotFoundException { + + return userRepository.findById(Long.parseLong(id)) + .orElseThrow(() -> new UsernameNotFoundException(ResponseCode.USER_NOT_FOUND.getMessage())); + } +} diff --git a/src/main/java/com/diareat/diareat/auth/service/KakaoAuthService.java b/src/main/java/com/diareat/diareat/auth/service/KakaoAuthService.java new file mode 100644 index 0000000..c1a9bb2 --- /dev/null +++ b/src/main/java/com/diareat/diareat/auth/service/KakaoAuthService.java @@ -0,0 +1,35 @@ +package com.diareat.diareat.auth.service; + +import com.diareat.diareat.auth.component.KakaoUserInfo; +import com.diareat.diareat.auth.dto.KakaoUserInfoResponse; +import com.diareat.diareat.user.domain.User; +import com.diareat.diareat.user.dto.request.CreateUserDto; +import com.diareat.diareat.user.dto.request.JoinUserDto; +import com.diareat.diareat.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@RequiredArgsConstructor +@Service +public class KakaoAuthService { + + private final KakaoUserInfo kakaoUserInfo; + private final UserRepository userRepository; + + @Transactional(readOnly = true) + public Long isSignedUp(String token) { // 클라이언트가 보낸 token을 이용해 카카오 API에 유저 정보를 요청, 유저가 존재하지 않으면 null, 존재하면 회원번호 반환 + KakaoUserInfoResponse userInfo = kakaoUserInfo.getUserInfo(token); + Optional userOptional = userRepository.findByKeyCode(userInfo.getId().toString()); + return userOptional.map(User::getId).orElse(null); + } + + @Transactional(readOnly = true) + public CreateUserDto createUserDto(JoinUserDto joinUserDto) { // 카카오로부터 프사 URL, 유저 고유ID를 얻어온 후, 이를 유저가 입력한 정보와 함께 CreateUserDto로 반환 + KakaoUserInfoResponse userInfo = kakaoUserInfo.getUserInfo(joinUserDto.getToken()); + return CreateUserDto.of(joinUserDto.getNickName(), userInfo.getKakao_account().getProfile().getProfile_image_url(), + userInfo.getId().toString(), joinUserDto.getGender(), joinUserDto.getHeight(), joinUserDto.getWeight(), joinUserDto.getAge()); + } +} diff --git a/src/main/java/com/diareat/diareat/config/JpaAuditingConfig.java b/src/main/java/com/diareat/diareat/config/JpaAuditingConfig.java new file mode 100644 index 0000000..c4aac34 --- /dev/null +++ b/src/main/java/com/diareat/diareat/config/JpaAuditingConfig.java @@ -0,0 +1,9 @@ +package com.diareat.diareat.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/diareat/diareat/config/RedisCacheConfig.java b/src/main/java/com/diareat/diareat/config/RedisCacheConfig.java new file mode 100644 index 0000000..b54f050 --- /dev/null +++ b/src/main/java/com/diareat/diareat/config/RedisCacheConfig.java @@ -0,0 +1,35 @@ +package com.diareat.diareat.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.time.Duration; + +@EnableCaching +@Configuration +public class RedisCacheConfig { // Redis Cache 설정 + + @Value("${spring.redis.host}") + private String host; + + @Value("${spring.redis.port}") + private int port; + + @Bean + public CacheManager diareatCacheManager(RedisConnectionFactory cf) { + RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())) + .entryTtl(Duration.ofMinutes(3L)); // 캐시 만료 시간 3분 + return RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(cf).cacheDefaults(redisCacheConfiguration).build(); + } +} diff --git a/src/main/java/com/diareat/diareat/config/SwaggerConfig.java b/src/main/java/com/diareat/diareat/config/SwaggerConfig.java new file mode 100644 index 0000000..99bf20f --- /dev/null +++ b/src/main/java/com/diareat/diareat/config/SwaggerConfig.java @@ -0,0 +1,59 @@ +package com.diareat.diareat.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import springfox.documentation.builders.ApiInfoBuilder; +import springfox.documentation.builders.PathSelectors; +import springfox.documentation.builders.RequestHandlerSelectors; +import springfox.documentation.service.ApiInfo; +import springfox.documentation.service.ApiKey; +import springfox.documentation.service.AuthorizationScope; +import springfox.documentation.service.SecurityReference; +import springfox.documentation.spi.DocumentationType; +import springfox.documentation.spi.service.contexts.SecurityContext; +import springfox.documentation.spring.web.plugins.Docket; +import springfox.documentation.swagger2.annotations.EnableSwagger2; + +import java.util.Collections; +import java.util.List; + +@Configuration +@EnableSwagger2 +public class SwaggerConfig implements WebMvcConfigurer { + + @Bean + public Docket api() { + return new Docket(DocumentationType.SWAGGER_2) + .select() + .apis(RequestHandlerSelectors.any()) + .paths(PathSelectors.any()) + .build() + .apiInfo(apiInfo()) + .securityContexts(Collections.singletonList(securityContext())) + .securitySchemes(List.of(apiKey())); + } + + private ApiInfo apiInfo() { + return new ApiInfoBuilder() + .title("Diareat API") + .description("Diareat API 설명서") + .version("1.1") + .build(); + } + + private ApiKey apiKey() { + return new ApiKey("JWT", "Authorization", "header"); + } + + private SecurityContext securityContext() { + return SecurityContext.builder().securityReferences(defaultAuth()).build(); + } + + private List defaultAuth() { + AuthorizationScope authorizationScope = new AuthorizationScope("global", "accessEveryThing"); + AuthorizationScope[] authorizationScopes = new AuthorizationScope[1]; + authorizationScopes[0] = authorizationScope; + return List.of(new SecurityReference("JWT", authorizationScopes)); + } +} diff --git a/src/main/java/com/diareat/diareat/config/WebClientConfig.java b/src/main/java/com/diareat/diareat/config/WebClientConfig.java new file mode 100644 index 0000000..80d7496 --- /dev/null +++ b/src/main/java/com/diareat/diareat/config/WebClientConfig.java @@ -0,0 +1,39 @@ +package com.diareat.diareat.config; + +import io.netty.channel.ChannelOption; +import io.netty.handler.timeout.ReadTimeoutHandler; +import io.netty.handler.timeout.WriteTimeoutHandler; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.http.client.reactive.ReactorResourceFactory; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.netty.http.client.HttpClient; + +import java.time.Duration; +import java.util.function.Function; + +@Configuration +public class WebClientConfig { + + @Bean + public ReactorResourceFactory resourceFactory() { + ReactorResourceFactory factory = new ReactorResourceFactory(); + factory.setUseGlobalResources(false); + return factory; + } + + @Bean + public WebClient webClient() { + Function mapper = client -> HttpClient.create() + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 1000) + .doOnConnected(connection -> connection.addHandlerLast(new ReadTimeoutHandler(10)) + .addHandlerLast(new WriteTimeoutHandler(10))) + .responseTimeout(Duration.ofSeconds(1)); + + ClientHttpConnector connector = + new ReactorClientHttpConnector(resourceFactory(), mapper); + return WebClient.builder().clientConnector(connector).build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/diareat/diareat/config/WebSecurityConfig.java b/src/main/java/com/diareat/diareat/config/WebSecurityConfig.java new file mode 100644 index 0000000..0bd9f3a --- /dev/null +++ b/src/main/java/com/diareat/diareat/config/WebSecurityConfig.java @@ -0,0 +1,37 @@ +package com.diareat.diareat.config; + +import com.diareat.diareat.auth.component.JwtAuthFilter; +import com.diareat.diareat.auth.component.JwtTokenProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@RequiredArgsConstructor +@EnableWebSecurity +public class WebSecurityConfig { + + private final JwtTokenProvider jwtTokenProvider; + + private static final String[] AUTH_LIST = { // swagger 관련 URl + "/v2/api-docs", "/swagger-resources/**", "/swagger-ui.html", "/swagger-ui/**" + }; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf().disable() + .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) + .and() + .authorizeRequests() + .antMatchers(AUTH_LIST).permitAll() // swagger 관련 URL은 인증 없이 접근 가능 (테스트용) + .antMatchers("/api/auth/**").permitAll() // 회원가입/로그인 관련 URL은 인증 없이 접근 가능 + .anyRequest().authenticated() // 나머지 모든 URL은 Jwt 인증 필요 + .and() + .addFilterBefore(new JwtAuthFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class); + return http.build(); + } +} diff --git a/src/main/java/com/diareat/diareat/food/controller/FoodController.java b/src/main/java/com/diareat/diareat/food/controller/FoodController.java new file mode 100644 index 0000000..b682b75 --- /dev/null +++ b/src/main/java/com/diareat/diareat/food/controller/FoodController.java @@ -0,0 +1,176 @@ +package com.diareat.diareat.food.controller; + +import com.diareat.diareat.auth.component.JwtTokenProvider; +import com.diareat.diareat.food.dto.*; +import com.diareat.diareat.food.service.FoodService; +import com.diareat.diareat.user.dto.response.ResponseRankUserDto; +import com.diareat.diareat.util.api.ApiResponse; +import com.diareat.diareat.util.api.ResponseCode; +import io.swagger.annotations.Api; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import java.time.LocalDate; +import java.util.List; + +@Api(tags = "2. food") +@RequiredArgsConstructor +@RestController +@RequestMapping("api/food") +public class FoodController { + + private final FoodService foodService; + private final JwtTokenProvider jwtTokenProvider; + + //음식 정보 저장 + @Operation(summary = "[음식] 음식 정보 저장", description = "촬영한 음식 정보를 저장합니다.") + @PostMapping("/save") + public ApiResponse saveFood(@RequestBody @Valid CreateFoodDto createFoodDto) { + return ApiResponse.success(foodService.saveFood(createFoodDto), ResponseCode.FOOD_CREATE_SUCCESS.getMessage()); + } + + //특정 날짜에 먹은 음식 반환 + @Operation(summary = "[음식] 특정 날짜에 먹은 음식 목록 조회", description = "유저의 특정 날짜에 먹은 음식 목록을 조회합니다.") + @GetMapping + public ApiResponse> getFoodListByDate(@RequestHeader String accessToken, + @RequestParam int yy, + @RequestParam int mm, + @RequestParam int dd) { + Long userId = jwtTokenProvider.getUserPk(accessToken); + LocalDate date = LocalDate.of(yy, mm, dd); + return ApiResponse.success(foodService.getFoodListByDate(userId, date), ResponseCode.FOOD_READ_SUCCESS.getMessage()); + } + + //음식 정보 수정 + @Operation(summary = "[음식] 음식 정보 수정", description = "음식에 대한 정보를 수정합니다.") + @PostMapping("/update") + public ApiResponse updateFood(@RequestBody @Valid UpdateFoodDto updateFoodDto, + @RequestParam int yy, + @RequestParam int mm, + @RequestParam int dd) { + LocalDate date = LocalDate.of(yy, mm, dd); + foodService.updateFood(updateFoodDto, date); + return ApiResponse.success(null, ResponseCode.FOOD_UPDATE_SUCCESS.getMessage()); + } + + //음식 삭제 + @Operation(summary = "[음식] 음식 정보 삭제", description = "음식에 대한 정보를 삭제합니다.") + @DeleteMapping("/{foodId}/delete") + public ApiResponse deleteFood(@PathVariable Long foodId, @RequestHeader String accessToken, + @RequestParam int yy, + @RequestParam int mm, + @RequestParam int dd) { + Long userId = jwtTokenProvider.getUserPk(accessToken); + LocalDate date = LocalDate.of(yy, mm, dd); + foodService.deleteFood(foodId, userId, date); + return ApiResponse.success(null, ResponseCode.FOOD_DELETE_SUCCESS.getMessage()); + } + + //즐겨찾기에 음식 저장 + @Operation(summary = "[즐겨찾기] 즐겨찾기에 저장", description = "유저의 즐겨찾기에 음식을 저장합니다.") + @PostMapping("/favorite") + public ApiResponse saveFavoriteFood(@RequestBody @Valid CreateFavoriteFoodDto createFavoriteFoodDto) { + return ApiResponse.success(foodService.saveFavoriteFood(createFavoriteFoodDto), ResponseCode.FOOD_FAVORITE_CREATE_SUCCESS.getMessage()); + } + + //즐겨찾기 음식 리스트 반환 + @Operation(summary = "[즐겨찾기] 즐겨찾기 목록 조회", description = "유저의 즐겨찾기에 등록된 음식 목록을 조회합니다.") + @GetMapping("/favorite") + public ApiResponse> getFavoriteFoodList(@RequestHeader String accessToken) { + Long userId = jwtTokenProvider.getUserPk(accessToken); + return ApiResponse.success(foodService.getFavoriteFoodList(userId), ResponseCode.FOOD_FAVORITE_READ_SUCCESS.getMessage()); + } + + //즐겨찾기 음식 수정 + @Operation(summary = "[즐겨찾기] 즐겨찾기 수정", description = "유저의 즐겨찾기에 등록된 음식의 정보를 수정합니다.") + @PostMapping("/favorite/update") + public ApiResponse updateFavoriteFood(@RequestBody @Valid UpdateFavoriteFoodDto updateFavoriteFoodDto) { + foodService.updateFavoriteFood(updateFavoriteFoodDto); + return ApiResponse.success(null, ResponseCode.FOOD_FAVORITE_UPDATE_SUCCESS.getMessage()); + } + + //즐겨찾기 음식 해제 + @Operation(summary = "[즐겨찾기] 즐겨찾기 해제", description = "유저의 즐겨찾기에 등록된 음식을 해제합니다.") + @DeleteMapping("/favorite/{favoriteFoodId}") + public ApiResponse deleteFavoriteFood(@PathVariable Long favoriteFoodId, @RequestHeader String accessToken) { + Long userId = jwtTokenProvider.getUserPk(accessToken); + foodService.deleteFavoriteFood(favoriteFoodId, userId); + return ApiResponse.success(null, ResponseCode.FOOD_FAVORITE_DELETE_SUCCESS.getMessage()); + } + + //특정 날짜에 먹은 음식들의 영양성분별 총합 조회 + @Operation(summary = "[음식] 특정 날짜에 먹은 음식들의 영양성분 총합 조회", description = "특정 날짜에 유저가 먹은 음식들의 영양성분별 총합 및 권장섭취량에 대한 비율을 조회합니다.") + @GetMapping("/nutrition") + public ApiResponse getNutritionSumByDate(@RequestHeader String accessToken, + @RequestParam int yy, + @RequestParam int mm, + @RequestParam int dd) { + Long userId = jwtTokenProvider.getUserPk(accessToken); + LocalDate date = LocalDate.of(yy, mm, dd); + return ApiResponse.success(foodService.getNutritionSumByDate(userId, date), ResponseCode.FOOD_READ_SUCCESS.getMessage()); + } + + //"" 7일간 총합 조회 + @Operation(summary = "[음식] 최근 7일간 먹은 음식들의 영양성분 총합 조회", description = "최근 7일 간 유저가 먹은 음식들의 영양성분별 총합 및 권장섭취량에 대한 비율을 조회합니다.") + @GetMapping("/nutrition/recentWeek") + public ApiResponse getNutritionSumByWeek(@RequestHeader String accessToken, + @RequestParam int yy, + @RequestParam int mm, + @RequestParam int dd) { + Long userId = jwtTokenProvider.getUserPk(accessToken); + return ApiResponse.success(foodService.getNutritionSumByWeek(userId, yy, mm, dd), ResponseCode.FOOD_READ_SUCCESS.getMessage()); + } + + //"" 30일간 (1달간) 총합 조회 + @Operation(summary = "[음식] 최근 한달 간 먹은 음식들의 영양성분 총합 조회", description = "최근 한달 간 유저가 먹은 음식들의 영양성분별 총합 및 권장섭취량에 대한 비율을 조회합니다.") + @GetMapping("/nutrition/recentMonth") + public ApiResponse getNutritionSumByMonth(@RequestHeader String accessToken, + @RequestParam int yy, + @RequestParam int mm, + @RequestParam int dd) { + Long userId = jwtTokenProvider.getUserPk(accessToken); + return ApiResponse.success(foodService.getNutritionSumByMonth(userId, yy, mm, dd), ResponseCode.FOOD_READ_SUCCESS.getMessage()); + } + + //유저의 주간 식습관 점수와 best3, worst3 음식 조회 + @Operation(summary = "[음식] 유저의 주간 식습관 점수와 best3, worst3 음식 조회", description = "유저의 주간 식습관 점수와 best3, worst3 음식을 조회합니다.") + @GetMapping("/score") + public ApiResponse getScoreOfUserWithBestAndWorstFoods(@RequestHeader String accessToken, + @RequestParam int yy, + @RequestParam int mm, + @RequestParam int dd + ) { + Long userId = jwtTokenProvider.getUserPk(accessToken); + return ApiResponse.success(foodService.getScoreOfUserWithBestAndWorstFoods(userId, yy, mm, dd), ResponseCode.FOOD_READ_SUCCESS.getMessage()); + } + + //유저의 일기 분석 그래프 데이터 및 식습관 totalScore 조회 + @Operation(summary = "[음식] 유저의 일기 분석 그래프 데이터 및 주간 식습관 점수 조회", description = "유저의 일기 분석 그래프 데이터 및 식습관 점수를 조회합니다.") + @GetMapping("/analysis") + public ApiResponse getAnalysisOfUser(@RequestHeader String accessToken, + @RequestParam int yy, + @RequestParam int mm, + @RequestParam int dd) { + Long userId = jwtTokenProvider.getUserPk(accessToken); + return ApiResponse.success(foodService.getAnalysisOfUser(userId, yy, mm, dd), ResponseCode.FOOD_READ_SUCCESS.getMessage()); + } + + //유저의 식습관 점수를 기반으로 한 주간 랭킹 조회 + @Operation(summary = "[음식] 유저의 식습관 점수를 기반으로 한 주간 랭킹 조회", description = "유저의 식습관 점수를 기반으로 한 주간 랭킹을 조회합니다. (팔로잉 상대 기반)") + @GetMapping("/rank") + public ApiResponse> getUserRankByWeek(@RequestHeader String accessToken, + @RequestParam int yy, + @RequestParam int mm, + @RequestParam int dd) { + Long userId = jwtTokenProvider.getUserPk(accessToken); + return ApiResponse.success(foodService.getUserRankByWeek(userId, yy, mm, dd), ResponseCode.FOOD_RANK_READ_SUCCESS.getMessage()); + } + + @Operation(summary = "[음식] 즐겨찾기 음식으로 음식 생성", description = "즐겨찾기 음식으로 음식을 생성합니다.") + @PostMapping("/favorite/createfrom") + public ApiResponse createFoodFromFavoriteFood(@RequestBody @Valid CreateFoodFromFavoriteFoodDto createFoodFromFavoriteFoodDto) { + return ApiResponse.success(foodService.createFoodFromFavoriteFood(createFoodFromFavoriteFoodDto), ResponseCode.FOOD_CREATE_SUCCESS.getMessage()); + } +} diff --git a/src/main/java/com/diareat/diareat/food/domain/FavoriteFood.java b/src/main/java/com/diareat/diareat/food/domain/FavoriteFood.java new file mode 100644 index 0000000..4f69dbd --- /dev/null +++ b/src/main/java/com/diareat/diareat/food/domain/FavoriteFood.java @@ -0,0 +1,65 @@ +package com.diareat.diareat.food.domain; + +import com.diareat.diareat.user.domain.BaseNutrition; +import com.diareat.diareat.user.domain.User; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.persistence.*; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class FavoriteFood { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "favorite_food_id") + private Long id; + + private String name; + + //@OneToOne(fetch = FetchType.LAZY, cascade = {CascadeType.PERSIST}, orphanRemoval = false) // 즐찾음식이 삭제되어도 음식은 삭제되지 않음 + //@JoinColumn(name = "food_id") + @OneToMany(mappedBy = "favoriteFood", cascade = CascadeType.PERSIST, orphanRemoval = false) // 즐찾음식이 삭제되어도 음식은 삭제되지 않음 + private List foods = new ArrayList<>(); + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + private int count = 0; // 즐겨찾기에서 선택되어 바로 음식으로 추가된 횟수 + + private BaseNutrition baseNutrition; + + public void addCount(){ + this.count++; + } + + // 생성 메서드 + public static FavoriteFood createFavoriteFood(String name, User user, Food food, BaseNutrition baseNutrition) { + FavoriteFood favoriteFood = new FavoriteFood(); + favoriteFood.name = name; + favoriteFood.foods.add(food); + favoriteFood.user = user; + favoriteFood.baseNutrition = baseNutrition; + //food.setFavoriteFood(favoriteFood); 관계의 주인이 즐겨찾기로 명확하게 정해졌기에 주석처리 + return favoriteFood; + } + + // 즐겨찾기 음식 정보 수정 + public void updateFavoriteFood(String name, BaseNutrition baseNutrition) { + this.name = name; + this.baseNutrition = baseNutrition; + } + + // 즐겨찾기 음식 정보로 새 음식 정보 생성 + public static Food createFoodFromFavoriteFood(FavoriteFood favoriteFood) { + favoriteFood.addCount(); + LocalDate createdDate = LocalDate.now(); + return Food.createFood(favoriteFood.getName(), favoriteFood.getUser(), favoriteFood.getBaseNutrition(), createdDate.getYear(), createdDate.getMonthValue(), createdDate.getDayOfMonth()); + } +} diff --git a/src/main/java/com/diareat/diareat/food/domain/Food.java b/src/main/java/com/diareat/diareat/food/domain/Food.java new file mode 100644 index 0000000..b491c2f --- /dev/null +++ b/src/main/java/com/diareat/diareat/food/domain/Food.java @@ -0,0 +1,70 @@ +package com.diareat.diareat.food.domain; + +import com.diareat.diareat.user.domain.BaseNutrition; +import com.diareat.diareat.user.domain.User; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import javax.persistence.*; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@EntityListeners(AuditingEntityListener.class) +public class Food { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "food_id") + private Long id; + + private String name; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + //@OneToOne(fetch = FetchType.LAZY, cascade = {CascadeType.PERSIST}, orphanRemoval = false) // 음식이 삭제되어도 즐찾음식은 삭제되지 않음 + //@JoinColumn(name = "favorite_food_id") + @ManyToOne(fetch = FetchType.LAZY, cascade = {CascadeType.PERSIST}) // 다대일로 매핑하여 음식의 즐찾음식을 찾을 수 있도록 함 + @JoinColumn(name = "favorite_food_id") + private FavoriteFood favoriteFood; + + private LocalDate date; + + @CreatedDate + @Column(name = "added_time") //테이블과 매핑 + private LocalDateTime addedTime; //클라이언트에서 추가하도록 요청 보낸 timestamp + + private BaseNutrition baseNutrition; + + // 생성 메서드 + public static Food createFood(String name, User user, BaseNutrition baseNutrition, int year, int month, int day) { + Food food = new Food(); + food.name = name; + food.user = user; + food.date = LocalDate.of(year, month, day); + food.baseNutrition = baseNutrition; + + return food; + } + + // 음식 정보 수정 + public void updateFood(String name, BaseNutrition baseNutrition) { + this.name = name; + this.baseNutrition = baseNutrition; + } + + public boolean isFavorite() { + return this.favoriteFood != null; + } + + public void setFavoriteFood(FavoriteFood favoriteFood){ + this.favoriteFood = favoriteFood; + } +} diff --git a/src/main/java/com/diareat/diareat/food/dto/CreateFavoriteFoodDto.java b/src/main/java/com/diareat/diareat/food/dto/CreateFavoriteFoodDto.java new file mode 100644 index 0000000..761602b --- /dev/null +++ b/src/main/java/com/diareat/diareat/food/dto/CreateFavoriteFoodDto.java @@ -0,0 +1,32 @@ +package com.diareat.diareat.food.dto; + +import com.diareat.diareat.user.domain.BaseNutrition; +import com.diareat.diareat.util.MessageUtil; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class CreateFavoriteFoodDto { + + @NotNull(message = MessageUtil.NOT_NULL) + private Long foodId; + + @NotNull(message = MessageUtil.NOT_NULL) + private Long userId; + + @NotBlank(message = MessageUtil.NOT_BLANK) + private String name; + + @NotNull(message = MessageUtil.NOT_NULL) + private BaseNutrition baseNutrition; + + public static CreateFavoriteFoodDto of(Long foodId, Long userId, String name, BaseNutrition baseNutrition) { + return new CreateFavoriteFoodDto(foodId, userId, name, baseNutrition); + } +} diff --git a/src/main/java/com/diareat/diareat/food/dto/CreateFoodDto.java b/src/main/java/com/diareat/diareat/food/dto/CreateFoodDto.java new file mode 100644 index 0000000..fd982c3 --- /dev/null +++ b/src/main/java/com/diareat/diareat/food/dto/CreateFoodDto.java @@ -0,0 +1,41 @@ +package com.diareat.diareat.food.dto; + +import com.diareat.diareat.user.domain.BaseNutrition; +import com.diareat.diareat.util.MessageUtil; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.PastOrPresent; +import java.time.LocalDate; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class CreateFoodDto { + + @NotNull(message = MessageUtil.NOT_NULL) + private Long userId; + + @NotBlank(message = MessageUtil.NOT_BLANK) + private String name; + + @NotNull(message = MessageUtil.NOT_NULL) + private BaseNutrition baseNutrition; + + private int year; + + private int month; + + private int day; + + public static CreateFoodDto of(Long userId, String name, BaseNutrition baseNutrition, int year, int month, int day) { + return new CreateFoodDto(userId, name, baseNutrition, year, month, day); + } + + public LocalDate getDate() { + return LocalDate.of(year, month, day); + } +} diff --git a/src/main/java/com/diareat/diareat/food/dto/CreateFoodFromFavoriteFoodDto.java b/src/main/java/com/diareat/diareat/food/dto/CreateFoodFromFavoriteFoodDto.java new file mode 100644 index 0000000..28011b9 --- /dev/null +++ b/src/main/java/com/diareat/diareat/food/dto/CreateFoodFromFavoriteFoodDto.java @@ -0,0 +1,23 @@ +package com.diareat.diareat.food.dto; + +import com.diareat.diareat.util.MessageUtil; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.NotNull; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class CreateFoodFromFavoriteFoodDto { + @NotNull(message = MessageUtil.NOT_NULL) + private Long userId; + + @NotNull(message = MessageUtil.NOT_NULL) + private Long favoriteFoodId; + + public static CreateFoodFromFavoriteFoodDto of(Long userId, Long favoriteFoodId) { + return new CreateFoodFromFavoriteFoodDto(userId, favoriteFoodId); + } +} diff --git a/src/main/java/com/diareat/diareat/food/dto/ResponseAnalysisDto.java b/src/main/java/com/diareat/diareat/food/dto/ResponseAnalysisDto.java new file mode 100644 index 0000000..8780f35 --- /dev/null +++ b/src/main/java/com/diareat/diareat/food/dto/ResponseAnalysisDto.java @@ -0,0 +1,31 @@ +package com.diareat.diareat.food.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ResponseAnalysisDto { // 그래프 + 점수에 사용되는 DTO + + private double totalScore; + private List> calorieLastSevenDays; // 최근 7일간의 칼로리 (7개 과거부터 나열) + private List calorieLastFourWeek; // 최근 4주간의 칼로리 (4개 과거부터 나열) + private List> carbohydrateLastSevenDays; // 최근 7일간의 탄수화물 + private List carbohydrateLastFourWeek; // 최근 4주간의 탄수화물 + private List> proteinLastSevenDays; // 최근 7일간의 단백질 + private List proteinLastFourWeek; // 최근 4주간의 단백질 + private List> fatLastSevenDays; // 최근 7일간의 지방 + private List fatLastFourWeek; // 최근 4주간의 지방 + + public static ResponseAnalysisDto of(double totalScore, List> calorieLastSevenDays, List calorieLastFourWeek,List> carbohydrateLastSevenDays, List carbohydrateLastFourWeek,List> proteinLastSevenDays, List proteinLastFourWeek, List> fatLastSevenDays, List fatLastFourWeek) { + return new ResponseAnalysisDto(totalScore, calorieLastSevenDays, calorieLastFourWeek, carbohydrateLastSevenDays, carbohydrateLastFourWeek, proteinLastSevenDays, proteinLastFourWeek, fatLastSevenDays, fatLastFourWeek); + } +} diff --git a/src/main/java/com/diareat/diareat/food/dto/ResponseFavoriteFoodDto.java b/src/main/java/com/diareat/diareat/food/dto/ResponseFavoriteFoodDto.java new file mode 100644 index 0000000..90c0f67 --- /dev/null +++ b/src/main/java/com/diareat/diareat/food/dto/ResponseFavoriteFoodDto.java @@ -0,0 +1,33 @@ +package com.diareat.diareat.food.dto; + +import com.diareat.diareat.food.domain.FavoriteFood; +import com.diareat.diareat.user.domain.BaseNutrition; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ResponseFavoriteFoodDto { + + private Long favoriteFoodId; + private String name; + private BaseNutrition baseNutrition; + private int count; + + public static ResponseFavoriteFoodDto of(Long favoriteFoodId, String name, BaseNutrition baseNutrition, int count) { + return new ResponseFavoriteFoodDto(favoriteFoodId, name, baseNutrition, count); + } + + public static ResponseFavoriteFoodDto from(FavoriteFood favoriteFood) { + return new ResponseFavoriteFoodDto( + favoriteFood.getId(), + favoriteFood.getName(), + favoriteFood.getBaseNutrition(), + favoriteFood.getCount() + ); + } +} diff --git a/src/main/java/com/diareat/diareat/food/dto/ResponseFoodDto.java b/src/main/java/com/diareat/diareat/food/dto/ResponseFoodDto.java new file mode 100644 index 0000000..ca0ee26 --- /dev/null +++ b/src/main/java/com/diareat/diareat/food/dto/ResponseFoodDto.java @@ -0,0 +1,50 @@ +package com.diareat.diareat.food.dto; + +import com.diareat.diareat.food.domain.Food; +import com.diareat.diareat.user.domain.BaseNutrition; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalTime; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ResponseFoodDto { + + private Long foodId; + private Long userId; + private String name; + private BaseNutrition baseNutrition; + private boolean favoriteChecked; + private int hour; + private int minute; + + public static ResponseFoodDto of(Long foodId, Long userId, String name, BaseNutrition baseNutrition, boolean favoriteChecked, int hour, int minute) { + return ResponseFoodDto.builder() + .foodId(foodId) + .userId(userId) + .name(name) + .baseNutrition(baseNutrition) + .favoriteChecked(favoriteChecked) + .hour(hour) + .minute(minute) + .build(); + } + + public static ResponseFoodDto from(Food food) { + return ResponseFoodDto.builder() + .foodId(food.getId()) + .userId(food.getUser().getId()) + .name(food.getName()) + .baseNutrition(food.getBaseNutrition()) + .favoriteChecked(food.isFavorite()) + .hour(food.getAddedTime().getHour()) + .minute(food.getAddedTime().getMinute()) + .build(); + } +} diff --git a/src/main/java/com/diareat/diareat/food/dto/ResponseFoodRankDto.java b/src/main/java/com/diareat/diareat/food/dto/ResponseFoodRankDto.java new file mode 100644 index 0000000..29fcf45 --- /dev/null +++ b/src/main/java/com/diareat/diareat/food/dto/ResponseFoodRankDto.java @@ -0,0 +1,25 @@ +package com.diareat.diareat.food.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ResponseFoodRankDto { + + private Long userId; + private List rankFoodList; + private LocalDate startDate; //해당 날짜로부터 7일전까지 + private boolean isBest; //isBest = true 이면 Best 3, false 이면 Worst 3 + + public static ResponseFoodRankDto of(Long userId, List rankFoodList, LocalDate startDate, boolean isBest) { + return new ResponseFoodRankDto(userId, rankFoodList, startDate, isBest); + } +} diff --git a/src/main/java/com/diareat/diareat/food/dto/ResponseNutritionSumByDateDto.java b/src/main/java/com/diareat/diareat/food/dto/ResponseNutritionSumByDateDto.java new file mode 100644 index 0000000..9eb8630 --- /dev/null +++ b/src/main/java/com/diareat/diareat/food/dto/ResponseNutritionSumByDateDto.java @@ -0,0 +1,44 @@ +package com.diareat.diareat.food.dto; + +import com.diareat.diareat.user.domain.BaseNutrition; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.time.LocalDate; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ResponseNutritionSumByDateDto implements Serializable { + + Long userId; + // LocalDate checkDate; //조회한 날짜 + int nutritionSumType; //조회할 기간을 나타내는 코드. {1: 특정 날짜, 7: 최근 7일간, 30: 최근 한달간} + + int totalKcal; + int totalCarbohydrate; + int totalProtein; + int totalFat; + + double ratioKcal; + double ratioCarbohydrate; + double ratioProtein; + double ratioFat; + + BaseNutrition baseNutrition; + + boolean isEmpty; + + public static ResponseNutritionSumByDateDto of (Long userId, LocalDate checkDate, int nutritionSumType, int totalKcal, + int totalCarbohydrate, int totalProtein, int totalFat, double ratioKcal, + double ratioCarbohydrate, double ratioProtein, double ratioFat, + BaseNutrition baseNutrition, boolean isEmpty){ + return new ResponseNutritionSumByDateDto(userId, nutritionSumType, totalKcal, + totalCarbohydrate, totalProtein, totalFat, ratioKcal, + ratioCarbohydrate, ratioProtein, ratioFat, baseNutrition, isEmpty); + } +} diff --git a/src/main/java/com/diareat/diareat/food/dto/ResponseScoreBestWorstDto.java b/src/main/java/com/diareat/diareat/food/dto/ResponseScoreBestWorstDto.java new file mode 100644 index 0000000..aa03997 --- /dev/null +++ b/src/main/java/com/diareat/diareat/food/dto/ResponseScoreBestWorstDto.java @@ -0,0 +1,27 @@ +package com.diareat.diareat.food.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ResponseScoreBestWorstDto { // 일기 분석 자세히보기에 사용되는 DTO + + private double calorieScore; + private double carbohydrateScore; + private double proteinScore; + private double fatScore; + private double totalScore; + private List best; + private List worst; + + public static ResponseScoreBestWorstDto of(double calorieScore, double carbohydrateScore, double proteinScore, double fatScore, double totalScore, List best, List worst) { + return new ResponseScoreBestWorstDto(calorieScore, carbohydrateScore, proteinScore, fatScore, totalScore, best, worst); + } +} diff --git a/src/main/java/com/diareat/diareat/food/dto/UpdateFavoriteFoodDto.java b/src/main/java/com/diareat/diareat/food/dto/UpdateFavoriteFoodDto.java new file mode 100644 index 0000000..725b69e --- /dev/null +++ b/src/main/java/com/diareat/diareat/food/dto/UpdateFavoriteFoodDto.java @@ -0,0 +1,32 @@ +package com.diareat.diareat.food.dto; + +import com.diareat.diareat.user.domain.BaseNutrition; +import com.diareat.diareat.util.MessageUtil; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class UpdateFavoriteFoodDto { + + @NotNull(message = MessageUtil.NOT_NULL) + private Long favoriteFoodId; + + @NotNull(message = MessageUtil.NOT_NULL) + private Long userId; + + @NotBlank(message = MessageUtil.NOT_BLANK) + private String name; + + @NotNull(message = MessageUtil.NOT_NULL) + private BaseNutrition baseNutrition; + + public static UpdateFavoriteFoodDto of(Long favoriteFoodId, Long userId, String name, BaseNutrition baseNutrition) { + return new UpdateFavoriteFoodDto(favoriteFoodId, userId, name, baseNutrition); + } +} diff --git a/src/main/java/com/diareat/diareat/food/dto/UpdateFoodDto.java b/src/main/java/com/diareat/diareat/food/dto/UpdateFoodDto.java new file mode 100644 index 0000000..b62fb24 --- /dev/null +++ b/src/main/java/com/diareat/diareat/food/dto/UpdateFoodDto.java @@ -0,0 +1,32 @@ +package com.diareat.diareat.food.dto; + +import com.diareat.diareat.user.domain.BaseNutrition; +import com.diareat.diareat.util.MessageUtil; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class UpdateFoodDto { + + @NotNull(message = MessageUtil.NOT_NULL) + private Long foodId; + + @NotNull(message = MessageUtil.NOT_NULL) + private Long userId; + + @NotBlank(message = MessageUtil.NOT_BLANK) + private String name; + + @NotNull(message = MessageUtil.NOT_NULL) + private BaseNutrition baseNutrition; + + public static UpdateFoodDto of(Long foodId, Long userId, String name, BaseNutrition baseNutrition) { + return new UpdateFoodDto(foodId, userId, name, baseNutrition); + } +} diff --git a/src/main/java/com/diareat/diareat/food/repository/FavoriteFoodRepository.java b/src/main/java/com/diareat/diareat/food/repository/FavoriteFoodRepository.java new file mode 100644 index 0000000..5514240 --- /dev/null +++ b/src/main/java/com/diareat/diareat/food/repository/FavoriteFoodRepository.java @@ -0,0 +1,11 @@ +package com.diareat.diareat.food.repository; + +import com.diareat.diareat.food.domain.FavoriteFood; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface FavoriteFoodRepository extends JpaRepository { + List findAllByUserId(Long userId); + boolean existsByIdAndUserId(Long id, Long userId); // 유저가 즐겨찾기에 추가한 음식인지 확인 +} diff --git a/src/main/java/com/diareat/diareat/food/repository/FoodRepository.java b/src/main/java/com/diareat/diareat/food/repository/FoodRepository.java new file mode 100644 index 0000000..41a01cb --- /dev/null +++ b/src/main/java/com/diareat/diareat/food/repository/FoodRepository.java @@ -0,0 +1,19 @@ +package com.diareat.diareat.food.repository; + +import com.diareat.diareat.food.domain.Food; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; + +public interface FoodRepository extends JpaRepository { + boolean existsByIdAndUserId(Long id, Long userId); // 유저가 먹은 음식인지 확인 + List findAllByUserIdAndDateOrderByAddedTimeAsc(Long userId, LocalDate date); //유저가 특정 날짜에 먹은 음식 반환 + List findAllByUserIdAndDateBetweenOrderByAddedTimeAsc(Long userId, LocalDate startDate, LocalDate endDate); // 유저가 특정 기간 내에 먹은 음식 반환 + + // 특정 즐겨찾기 음식으로부터 태어난 음식 데이터의, 즐겨찾기 음식과의 연관관계를 해제 + @Query(value = "UPDATE Food f set f.favoriteFood = null where f.favoriteFood.id = :id") + void updateFoodRelationShipWithFavoriteFood(@Param("id")Long favoriteFoodId); +} diff --git a/src/main/java/com/diareat/diareat/food/service/FoodService.java b/src/main/java/com/diareat/diareat/food/service/FoodService.java new file mode 100644 index 0000000..85527cf --- /dev/null +++ b/src/main/java/com/diareat/diareat/food/service/FoodService.java @@ -0,0 +1,528 @@ +package com.diareat.diareat.food.service; + +import com.diareat.diareat.food.domain.FavoriteFood; +import com.diareat.diareat.food.domain.Food; +import com.diareat.diareat.food.dto.*; +import com.diareat.diareat.food.repository.FavoriteFoodRepository; +import com.diareat.diareat.food.repository.FoodRepository; +import com.diareat.diareat.user.domain.BaseNutrition; +import com.diareat.diareat.user.domain.User; +import com.diareat.diareat.user.dto.response.ResponseRankUserDto; +import com.diareat.diareat.user.repository.FollowRepository; +import com.diareat.diareat.user.repository.UserRepository; +import com.diareat.diareat.util.api.ResponseCode; +import com.diareat.diareat.util.exception.FavoriteException; +import com.diareat.diareat.util.exception.FoodException; +import com.diareat.diareat.util.exception.UserException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@RequiredArgsConstructor +@Service +public class FoodService { + + private final FoodRepository foodRepository; // 유저:음식은 1:다 + private final FavoriteFoodRepository favoriteFoodRepository; // 유저:즐찾음식은 1:다 + private final FollowRepository followRepository; + private final UserRepository userRepository; + + // 촬영 후, 음식 정보 저장 + // @CacheEvict(value = {"ResponseNutritionSumByDateDto"}, key = "#userId+createFoodDto.getDate()", cacheManager = "diareatCacheManager") + @Transactional + public Long saveFood(CreateFoodDto createFoodDto) { + User user = getUserById(createFoodDto.getUserId()); + Food food = Food.createFood(createFoodDto.getName(), user, createFoodDto.getBaseNutrition(), createFoodDto.getYear(), createFoodDto.getMonth(), createFoodDto.getDay()); + log.info("신규 음식 저장: " + food.getName()); + return foodRepository.save(food).getId(); + } + + // 회원이 특정 날짜에 먹은 음식 조회 -> 돌발적인 행동이 많아 캐시 일관성 유지의 가치가 낮아서 캐싱 배제 + @Transactional(readOnly = true) + public List getFoodListByDate(Long userId, LocalDate date){ + validateUser(userId); + List foodList = foodRepository.findAllByUserIdAndDateOrderByAddedTimeAsc(userId, date); + log.info(date.toString() + "의 "+ userId + "에게 조회된 음식 개수: " + foodList.size() + "개"); + return foodList.stream() + .map(food -> ResponseFoodDto.builder() + .foodId(food.getId()) + .userId(food.getUser().getId()) + .name(food.getName()) + .baseNutrition(food.getBaseNutrition()) + .favoriteChecked(food.isFavorite()) + .hour(food.getAddedTime().getHour()) + .minute(food.getAddedTime().getMinute()) + .build()) + .collect(Collectors.toList()); + } + + // 음식 정보 수정 + // @CacheEvict(value = {"ResponseNutritionSumByDateDto"}, key = "#updateFoodDto.getUserId()+date.toString()", cacheManager = "diareatCacheManager") + @Transactional + public void updateFood(UpdateFoodDto updateFoodDto, LocalDate date) { + Food food = getFoodById(updateFoodDto.getFoodId()); + food.updateFood(updateFoodDto.getName(), updateFoodDto.getBaseNutrition()); + log.info("음식 정보 수정 완료: " + food.getName()); + foodRepository.save(food); + } + + // 음식 삭제 + // @CacheEvict(value = {"ResponseNutritionSumByDateDto"}, key = "#userId+date.toString()", cacheManager = "diareatCacheManager") + @Transactional + public void deleteFood(Long foodId, Long userId, LocalDate date) { + validateFood(foodId, userId); + foodRepository.deleteById(foodId); + log.info("음식 삭제 완료: " + foodId); + } + + // 즐겨찾기에 음식 저장 + // @CacheEvict(value = "ResponseFavoriteFoodDto", key = "#createFavoriteFoodDto.getUserId()", cacheManager = "diareatCacheManager") + @Transactional + public Long saveFavoriteFood(CreateFavoriteFoodDto createFavoriteFoodDto) { + validateFood(createFavoriteFoodDto.getFoodId(), createFavoriteFoodDto.getUserId()); + User user = getUserById(createFavoriteFoodDto.getUserId()); + Food food = getFoodById(createFavoriteFoodDto.getFoodId()); + + if (food.isFavorite()) {// 이미 즐겨찾기에 추가된 음식인 경우 중복 추가 불가능 + log.info(food.getId() + ": 이미 즐겨찾기에 추가된 음식입니다."); + throw new FavoriteException(ResponseCode.FAVORITE_ALREADY_EXIST); + } + + FavoriteFood favoriteFood = FavoriteFood.createFavoriteFood(createFavoriteFoodDto.getName(), user, food, createFavoriteFoodDto.getBaseNutrition()); + food.setFavoriteFood(favoriteFood); + foodRepository.save(food); + log.info("즐겨찾기 음식 저장 완료: " + favoriteFood.getName()); + return favoriteFoodRepository.save(favoriteFood).getId(); + } + + //즐겨찾기 음식 리스트 반환 + // @Cacheable(value = "ResponseFavoriteFoodDto", key = "#userId", cacheManager = "diareatCacheManager") + @Transactional(readOnly = true) + public List getFavoriteFoodList(Long userId){ + validateUser(userId); + List foodList = favoriteFoodRepository.findAllByUserId(userId); + log.info(userId + "의 즐겨찾기 음식 개수: " + foodList.size() + "개 조회 완료"); + return foodList.stream() + .map(favoriteFood -> ResponseFavoriteFoodDto.builder() + .favoriteFoodId(favoriteFood.getId()) + .name(favoriteFood.getName()) + .baseNutrition(favoriteFood.getBaseNutrition()) + .count(favoriteFood.getCount()) + .build()) + .collect(Collectors.toList()); + } + + // 즐겨찾기 음식 수정 + // @CacheEvict(value = "ResponseFavoriteFoodDto", key = "updateFavoriteFoodDto.getUserId()", cacheManager = "diareatCacheManager") + @Transactional + public void updateFavoriteFood(UpdateFavoriteFoodDto updateFavoriteFoodDto) { + FavoriteFood food = getFavoriteFoodById(updateFavoriteFoodDto.getFavoriteFoodId()); + food.updateFavoriteFood(updateFavoriteFoodDto.getName(), updateFavoriteFoodDto.getBaseNutrition()); + favoriteFoodRepository.save(food); + log.info("즐겨찾기 음식 수정 완료: " + food.getName()); + } + + // 즐겨찾기 해제 + // @CacheEvict(value = "ResponseFavoriteFoodDto", key = "#userId", cacheManager = "diareatCacheManager") + @Transactional + public void deleteFavoriteFood(Long favoriteFoodId, Long userId) { + validateFavoriteFood(favoriteFoodId, userId); + favoriteFoodRepository.deleteById(favoriteFoodId); + foodRepository.updateFoodRelationShipWithFavoriteFood(favoriteFoodId); + log.info("즐겨찾기 음식 해제 완료: " + favoriteFoodId); + } + + // @Cacheable(value = "ResponseNutritionSumByDateDto", key = "#userId+#date.toString()", cacheManager = "diareatCacheManager") + @Transactional(readOnly = true) + // 유저의 특정 날짜에 먹은 음식들의 영양성분별 총합 조회 (섭취영양소/기준영양소 및 비율까지 계산해서 반환, dto 구체적 협의 필요) + public ResponseNutritionSumByDateDto getNutritionSumByDate(Long userId, LocalDate date) { + validateUser(userId); + List foodList = foodRepository.findAllByUserIdAndDateOrderByAddedTimeAsc(userId, date); + log.info(date.toString() + "기준 "+ userId + "의 음식들의 영양성분별 총합 조회 완료"); + return calculateNutritionSumAndRatio(userId, foodList, date, 1, foodList.isEmpty()); + } + + @Transactional(readOnly = true) + // 유저의 최근 7일간의 영양성분별 총합 조회 (섭취영양소/기준영양소 및 비율까지 계산해서 반환, dto 구체적 협의 필요) + public ResponseNutritionSumByDateDto getNutritionSumByWeek(Long userId, int year, int month, int day) { + validateUser(userId); + LocalDate endDate = LocalDate.of(year, month, day); + List foodList = foodRepository.findAllByUserIdAndDateBetweenOrderByAddedTimeAsc(userId, endDate.minusWeeks(1), endDate); + return calculateNutritionSumAndRatio(userId, foodList, endDate, 7, foodList.isEmpty()); + } + + @Transactional(readOnly = true) + // 유저의 최근 1개월간의 영양성분별 총합 조회 (섭취영양소/기준영양소 및 비율까지 계산해서 반환, dto 구체적 협의 필요) + public ResponseNutritionSumByDateDto getNutritionSumByMonth(Long userId, int year, int month, int day) { + validateUser(userId); + LocalDate endDate = LocalDate.of(year, month, day); + List foodList = foodRepository.findAllByUserIdAndDateBetweenOrderByAddedTimeAsc(userId, endDate.minusMonths(1), endDate); + + return calculateNutritionSumAndRatio(userId, foodList, endDate, 30, foodList.isEmpty()); + } + + @Transactional(readOnly = true) + // 유저의 최근 7일간의 Best 3 음식 조회 (dto 구체적 협의 필요) + public ResponseFoodRankDto getBestFoodByWeek(Long userId, int year, int month, int day) { + validateUser(userId); + LocalDate endDate = LocalDate.of(year, month, day); + List foodList = foodRepository.findAllByUserIdAndDateBetweenOrderByAddedTimeAsc(userId, endDate.minusWeeks(1), endDate); + + List top3Foods = foodList.stream() + .sorted(Comparator.comparingDouble((Food food) -> + 0.7 * food.getBaseNutrition().getProtein()- 0.3 * food.getBaseNutrition().getFat()).reversed()) + .limit(3) + .collect(Collectors.toList()); //고단백 저지방일수록 점수를 높게 측정되도록 기준을 잡은 후, 그 기준을 기반으로 정렬 + //사용한 기준은, 고단백과 저지방의 점수 반영 비율을 7:3으로 측정하고, 단백질량이 높을 수록, 지방량이 낮을 수록 점수가 높음. 이후, 내림차순 정렬 + // ** Best 3 기준 논의 필요 ** + + List top3FoodsDtoList = top3Foods.stream() + .map(food -> ResponseFoodDto.builder() + .name(food.getName()) + .baseNutrition(food.getBaseNutrition()) + .build()) + .collect(Collectors.toList()); + + return ResponseFoodRankDto.builder() + .userId(userId) + .rankFoodList(top3FoodsDtoList) + .startDate(endDate.minusWeeks(1)) + .isBest(true) + .build(); + } + + @Transactional(readOnly = true) + // 유저의 최근 7일간의 Worst 3 음식 조회 (dto 구체적 협의 필요) + public ResponseFoodRankDto getWorstFoodByWeek(Long userId, int year, int month, int day) { + validateUser(userId); + LocalDate endDate = LocalDate.of(year, month, day); + List foodList = foodRepository.findAllByUserIdAndDateBetweenOrderByAddedTimeAsc(userId, endDate.minusWeeks(1), endDate); + + List worst3Foods = foodList.stream() + .sorted(Comparator.comparingDouble((Food food) -> + 0.7 * food.getBaseNutrition().getFat() + 0.3 * food.getBaseNutrition().getCarbohydrate()).reversed()) + .limit(3) + .collect(Collectors.toList()); + //반대로 고지방 고탄수의 경우를 7:3으로 측정하고, 지방이 높을 수록 점수가 급격히 높아짐. 이 경우는 점수가 높은 것이 안좋음. + //(수정) https://blog.nongshim.com/1961, 탄수화물이 더 영향을 미친다고 하는데...흠... + // ** 이점은 논의가 필요할 듯? ** + // 우선 임시로 지방 비율을 높게 설정 + + List worst3FoodDtoList = worst3Foods.stream() + .map(food -> ResponseFoodDto.builder() + .name(food.getName()) + .baseNutrition(food.getBaseNutrition()) + .build()) + .collect(Collectors.toList()); + + return ResponseFoodRankDto.builder() + .userId(userId) + .rankFoodList(worst3FoodDtoList) + .startDate(endDate.minusWeeks(1)) + .isBest(false) + .build(); + } + + // 잔여 기능 구현 부분 + + //유저의 식습관 점수 및 Best 3와 Worst 3 계산 + @Transactional(readOnly = true) + public ResponseScoreBestWorstDto getScoreOfUserWithBestAndWorstFoods(Long userId, int year, int month, int day){ + validateUser(userId); //유저 객체 검증 + double totalScore = 0.0; + double kcalScore = 0.0; + double carbohydrateScore = 0.0; + double proteinScore = 0.0; + double fatScore = 0.0; + + User targetUser = userRepository.getReferenceById(userId); //검증된 id로 유저 객체 불러오기 + + ResponseRankUserDto scoresOfUser = calculateUserScoreThisWeek(targetUser, LocalDate.of(year, month, day).with(DayOfWeek.MONDAY), LocalDate.of(year, month, day)); + + totalScore = Math.round((scoresOfUser.getTotalScore() * 100.0))/ 100.0; + kcalScore = Math.round((scoresOfUser.getCalorieScore() * 100.0)) / 100.0; + carbohydrateScore = Math.round((scoresOfUser.getCarbohydrateScore() * 100.0)) / 100.0; + proteinScore = Math.round((scoresOfUser.getProteinScore() * 100.0)) / 100.0; + fatScore = Math.round((scoresOfUser.getFatScore() * 100.0 ))/ 100.0; + + + //Dto의 형식에 맞게 Best3와 Worst3 음식 계산 + List simpleBestFoodList = getBestFoodByWeek(userId, year, month, day).getRankFoodList(); + List simpleWorstFoodList = getWorstFoodByWeek(userId, year, month, day).getRankFoodList(); + + return ResponseScoreBestWorstDto.builder() + .totalScore(totalScore) + .calorieScore(kcalScore) + .carbohydrateScore(carbohydrateScore) + .proteinScore(proteinScore) + .fatScore(fatScore) + .best(simpleBestFoodList) + .worst(simpleWorstFoodList) + .build(); + } + + + + // 유저의 일기 분석 그래프 데이터 및 식습관 totalScore 조회 + @Transactional(readOnly = true) + public ResponseAnalysisDto getAnalysisOfUser(Long userId, int year, int month, int day){ + validateUser(userId); + User user = userRepository.getReferenceById(userId); + + //현재 날짜 + LocalDate currentDate = LocalDate.of(year,month,day).plusDays(1); + + //최근 1주간 유저가 먹은 음식들의 날짜별 HashMap + HashMap> nutritionSumOfUserByWeek = getNutritionSumByDateMap(userId, currentDate.minusWeeks(1), currentDate); + HashMap> nutritionSumOfUserByMonth = getNutritionSumByDateMap(userId, currentDate.minusWeeks(3).with(DayOfWeek.MONDAY), currentDate); + + double totalWeekScore = calculateUserScoreThisWeek(user, LocalDate.of(year, month, day).with(DayOfWeek.MONDAY), LocalDate.of(year, month, day)).getTotalScore(); + + List>calorieLastSevenDays = new ArrayList<>(); + List calorieLastFourWeek = new ArrayList<>(); + List> carbohydrateLastSevenDays = new ArrayList<>(); + List carbohydrateLastFourWeek = new ArrayList<>(); + List> proteinLastSevenDays = new ArrayList<>(); + List proteinLastFourWeek = new ArrayList<>(); + List> fatLastSevenDays = new ArrayList<>(); + List fatLastFourWeek = new ArrayList<>(); + + //최근 7일간의 식습관, 비어있으면 0으로 반환 + for (LocalDate date = currentDate.minusWeeks(1); date.isBefore(currentDate); date = date.plusDays(1)){ + if(!nutritionSumOfUserByWeek.containsKey(date)){ //해당 날짜에 먹은 음식이 없는 경우는 0으로 반환 + calorieLastSevenDays.add(Map.of(date, 0.0)); + carbohydrateLastSevenDays.add(Map.of(date, 0.0)); + proteinLastSevenDays.add(Map.of(date, 0.0)); + fatLastSevenDays.add(Map.of(date, 0.0)); + }else{ + int totalKcal = nutritionSumOfUserByWeek.get(date).stream().mapToInt(BaseNutrition::getKcal).sum(); + int totalCarbohydrate = nutritionSumOfUserByWeek.get(date).stream().mapToInt(BaseNutrition::getCarbohydrate).sum(); + int totalProtein = nutritionSumOfUserByWeek.get(date).stream().mapToInt(BaseNutrition::getProtein).sum(); + int totalFat = nutritionSumOfUserByWeek.get(date).stream().mapToInt(BaseNutrition::getFat).sum(); + + calorieLastSevenDays.add(Map.of(date, (double) totalKcal)); + carbohydrateLastSevenDays.add(Map.of(date, (double) totalCarbohydrate)); + proteinLastSevenDays.add(Map.of(date, (double) totalProtein)); + fatLastSevenDays.add(Map.of(date, (double) totalFat)); + } + } + + //최근 한달간의 식습관. 총 4주간의 데이터를 반환하며, 한주 단위로 묶어서 반환 + for (LocalDate date = currentDate.minusWeeks(3).with(DayOfWeek.MONDAY); date.isBefore(currentDate); date = date.plusWeeks(1)){ + int totalKcal = 0; + int totalCarbohydrate = 0; + int totalProtein = 0; + int totalFat = 0; + //해당 주에 먹은 음식들을 모두 합해서 계산 + for (LocalDate inner_date = date; inner_date.isBefore(date.plusWeeks(1)); inner_date = inner_date.plusDays(1)){ + if(nutritionSumOfUserByMonth.containsKey(inner_date)){ + totalKcal += nutritionSumOfUserByMonth.get(inner_date).stream().mapToInt(BaseNutrition::getKcal).sum(); + totalCarbohydrate += nutritionSumOfUserByMonth.get(inner_date).stream().mapToInt(BaseNutrition::getCarbohydrate).sum(); + totalProtein += nutritionSumOfUserByMonth.get(inner_date).stream().mapToInt(BaseNutrition::getProtein).sum(); + totalFat += nutritionSumOfUserByMonth.get(inner_date).stream().mapToInt(BaseNutrition::getFat).sum(); + } + } + calorieLastFourWeek.add((double) totalKcal); + carbohydrateLastFourWeek.add((double) totalCarbohydrate); + proteinLastFourWeek.add((double) totalProtein); + fatLastFourWeek.add((double) totalFat); + } + + totalWeekScore = Math.round(totalWeekScore * 100.0) / 100.0; + + return ResponseAnalysisDto.builder() + .totalScore(totalWeekScore) + .calorieLastSevenDays(calorieLastSevenDays) + .calorieLastFourWeek(calorieLastFourWeek) + .carbohydrateLastSevenDays(carbohydrateLastSevenDays) + .carbohydrateLastFourWeek(carbohydrateLastFourWeek) + .proteinLastSevenDays(proteinLastSevenDays) + .proteinLastFourWeek(proteinLastFourWeek) + .fatLastSevenDays(fatLastSevenDays) + .fatLastFourWeek(fatLastFourWeek) + .build(); + } + + + + // @Cacheable(value = "ResponseRankUserDto", key = "#userId", cacheManager = "diareatCacheManager") + @Transactional(readOnly = true) + // 유저의 식습관 점수를 기반으로 한 주간 랭킹 조회 + public List getUserRankByWeek(Long userId, int year, int month, int day) { + List rankUserDtos = new ArrayList<>(); + List users = followRepository.findAllByFromUser(userId); // 유저의 팔로우 유저 명단 + rankUserDtos.add(calculateUserScoreThisWeek(getUserById(userId), LocalDate.of(year, month, day).with(DayOfWeek.MONDAY), LocalDate.of(year, month ,day))); + if (users.isEmpty()) { // 팔로우한 유저가 없는 경우 본인의 점수 및 정보만 반환함 + return rankUserDtos; + } + + // 팔로우한 유저들의 점수를 계산하여 rankUserDtos에 추가 + for (User user : users) { + ResponseRankUserDto userDto = calculateUserScoreThisWeek(user, LocalDate.of(year, month, day).with(DayOfWeek.MONDAY), LocalDate.of(year, month, day)); + rankUserDtos.add(userDto); + } + + // 식습관 총점 기준 내림차순 정렬 + rankUserDtos.sort(Comparator.comparing(ResponseRankUserDto::getTotalScore).reversed()); + return rankUserDtos; + } + + @Transactional + // 즐겨찾기 음식으로부터 새로운 음식을 간편 등록 + public Long createFoodFromFavoriteFood(CreateFoodFromFavoriteFoodDto createFoodFromFavoriteFoodDto) { + validateFavoriteFood(createFoodFromFavoriteFoodDto.getFavoriteFoodId(), createFoodFromFavoriteFoodDto.getUserId()); + FavoriteFood favoriteFood = getFavoriteFoodById(createFoodFromFavoriteFoodDto.getFavoriteFoodId()); + Food food = FavoriteFood.createFoodFromFavoriteFood(favoriteFood); + food.setFavoriteFood(favoriteFood); + return foodRepository.save(food).getId(); + } + + private User getUserById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new UserException(ResponseCode.USER_NOT_FOUND)); + } + + private Food getFoodById(Long foodId) { + return foodRepository.findById(foodId) + .orElseThrow(() -> new FoodException(ResponseCode.FOOD_NOT_FOUND)); + } + + private FavoriteFood getFavoriteFoodById(Long foodId) { + return favoriteFoodRepository.findById(foodId) + .orElseThrow(() -> new FoodException(ResponseCode.FOOD_NOT_FOUND)); + } + + private ResponseRankUserDto calculateUserScoreThisWeek(User targetUser, LocalDate startDate, LocalDate endDate) { + Map> maps = getNutritionSumByDateMap(targetUser.getId(), startDate, endDate); + double kcalScore = 0.0; + double carbohydrateScore = 0.0; + double proteinScore = 0.0; + double fatScore = 0.0; + double totalScore; + + for (LocalDate date : maps.keySet()) { + // 해당 날짜에 먹은 음식들의 영양성분 총합 계산 + int totalKcal = maps.get(date).stream().mapToInt(BaseNutrition::getKcal).sum(); + int totalCarbohydrate = maps.get(date).stream().mapToInt(BaseNutrition::getCarbohydrate).sum(); + int totalProtein = maps.get(date).stream().mapToInt(BaseNutrition::getProtein).sum(); + int totalFat = maps.get(date).stream().mapToInt(BaseNutrition::getFat).sum(); + + // 기준섭취량 대비 섭취 비율에 매핑되는 식습관 점수 계산 + proteinScore += calculateNutriRatioAndScore(totalProtein, targetUser.getBaseNutrition().getProtein(), 0); + fatScore += calculateNutriRatioAndScore(totalFat, targetUser.getBaseNutrition().getFat(), 1); + carbohydrateScore += calculateNutriRatioAndScore(totalCarbohydrate, targetUser.getBaseNutrition().getCarbohydrate(), 1); + kcalScore += calculateNutriRatioAndScore(totalKcal, targetUser.getBaseNutrition().getKcal(), 1); + } + totalScore = (kcalScore + carbohydrateScore + proteinScore + fatScore); + return ResponseRankUserDto.builder() + .userId(targetUser.getId()) + .name(targetUser.getName()) + .totalScore(totalScore) + .calorieScore(kcalScore) + .carbohydrateScore(carbohydrateScore) + .proteinScore(proteinScore) + .fatScore(fatScore) + .build(); + } + + private ResponseNutritionSumByDateDto calculateNutritionSumAndRatio(Long userId, List foodList, LocalDate checkDate, int nutritionSumType, + boolean isEmpty) { + User targetUser = getUserById(userId); + int totalKcal = 0; + int totalCarbohydrate = 0; + int totalProtein = 0; + int totalFat = 0; + + for (Food food : foodList) { + BaseNutrition targetFoodNutrition = food.getBaseNutrition(); + totalKcal += targetFoodNutrition.getKcal(); + totalCarbohydrate += targetFoodNutrition.getCarbohydrate(); + totalProtein += targetFoodNutrition.getProtein(); + totalFat += targetFoodNutrition.getFat(); + } + + double ratioKcal = Math.round((((double) totalKcal / (double) targetUser.getBaseNutrition().getKcal()) * 100.0) * 10.0) / 10.0; + double ratioCarbohydrate = Math.round((((double) totalCarbohydrate / (double) targetUser.getBaseNutrition().getCarbohydrate()) * 100.0) * 10.0) / 10.0; + double ratioProtein = Math.round((((double) totalProtein / (double) targetUser.getBaseNutrition().getProtein()) * 100.0) * 10.0) / 10.0; + double ratioFat = Math.round((((double) totalFat / (double) targetUser.getBaseNutrition().getFat()) * 100.0) * 10.0) / 10.0; + + return ResponseNutritionSumByDateDto.builder() + .userId(userId) + .nutritionSumType(nutritionSumType) + .totalKcal(totalKcal) + .totalCarbohydrate(totalCarbohydrate) + .totalProtein(totalProtein) + .totalFat(totalFat) + .ratioKcal(ratioKcal) + .ratioCarbohydrate(ratioCarbohydrate) + .ratioProtein(ratioProtein) + .ratioFat(ratioFat) + .baseNutrition(targetUser.getBaseNutrition()) + .isEmpty(isEmpty) + .build(); + } + + private void validateUser(Long userId) { + if (!userRepository.existsById(userId)) + throw new UserException(ResponseCode.USER_NOT_FOUND); + } + + private void validateFood(Long foodId, Long userId) { + if(!foodRepository.existsById(foodId)) + throw new FoodException(ResponseCode.FOOD_NOT_FOUND); + if (!foodRepository.existsByIdAndUserId(foodId, userId)) // 음식의 주인이 유저인지 아닌지 판정 + throw new FoodException(ResponseCode.NOT_FOOD_OWNER); + } + + private void validateFavoriteFood(Long favoriteFoodId, Long userId) { + if(!favoriteFoodRepository.existsById(favoriteFoodId)) + throw new FavoriteException(ResponseCode.FAVORITE_NOT_FOUND); + if(!favoriteFoodRepository.existsByIdAndUserId(favoriteFoodId, userId)) // 즐겨찾는 음식의 주인이 유저인지 아닌지 판정 + throw new FavoriteException(ResponseCode.NOT_FOOD_OWNER); + } + + // 1주일동안 먹은 음식들의 영양성분 총합을 요일을 Key로 한 Map을 통해 반환 + private HashMap> getNutritionSumByDateMap(Long userId, LocalDate startDate, LocalDate endDate) { + HashMap> maps = new HashMap<>(); + List foodList = foodRepository.findAllByUserIdAndDateBetweenOrderByAddedTimeAsc(userId, startDate, endDate); + for (Food food : foodList) { + if (maps.containsKey(food.getDate())) { + maps.get(food.getDate()).add(food.getBaseNutrition()); + } else { + List baseNutritions = new ArrayList<>(); + baseNutritions.add(food.getBaseNutrition()); + maps.put(food.getDate(), baseNutritions); + } + } + return maps; + } + + // 영양성분 총합 대비 기준섭취량 비율을 계산하여 성분별 식습관 점수를 반환 + private double calculateNutriRatioAndScore(double total, double standard, int type) { + double ratio = Math.round(((total / standard) * 100.0) * 10.0) / 10.0; + if (type == 0) { // 단백질 + if (ratio < 100.0) return ratio; + else if (ratio <= 150) return 100; + else return (-2 * ratio + 400 < 0) ? 0 : (-2 * ratio + 400); + } else { // 칼탄지 + double gradient = 1.11; // (9분의 10) + if (ratio < 90.0) return ratio * gradient; + else if (ratio <= 110) return 100; + else return (-gradient * (ratio - 200) < 0) ? 0 : (-gradient * (ratio - 200)); + } + } + + /** + * 메서드 구현 유의사항 + * 1. 메서드명은 동사로 시작 + * 2. 유효성 검사는 private void validateUser의 형태로 분리 + * 3. Repository에서 가져오고 OrElseThrow로 예외처리하는 부분이 반복되는 경우 private ??? get???ById 형태 메서드로 분리 + */ +} diff --git a/src/main/java/com/diareat/diareat/user/controller/UserController.java b/src/main/java/com/diareat/diareat/user/controller/UserController.java new file mode 100644 index 0000000..06cd27b --- /dev/null +++ b/src/main/java/com/diareat/diareat/user/controller/UserController.java @@ -0,0 +1,95 @@ +package com.diareat.diareat.user.controller; + +import com.diareat.diareat.auth.component.JwtTokenProvider; +import com.diareat.diareat.user.dto.request.SearchUserDto; +import com.diareat.diareat.user.dto.request.UpdateUserDto; +import com.diareat.diareat.user.dto.request.UpdateUserNutritionDto; +import com.diareat.diareat.user.dto.response.ResponseSearchUserDto; +import com.diareat.diareat.user.dto.response.ResponseSimpleUserDto; +import com.diareat.diareat.user.dto.response.ResponseUserDto; +import com.diareat.diareat.user.dto.response.ResponseUserNutritionDto; +import com.diareat.diareat.user.service.UserService; +import com.diareat.diareat.util.api.ApiResponse; +import com.diareat.diareat.util.api.ResponseCode; +import io.swagger.annotations.Api; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import java.util.List; + +@Api(tags = "1. User") +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/user") +public class UserController { + + private final UserService userService; + private final JwtTokenProvider jwtTokenProvider; + + // 회원 기본정보 조회 + @Operation(summary = "[프로필] 회원 기본정보 조회", description = "회원 기본정보를 조회합니다.") + @GetMapping("/info/simple") + public ApiResponse getSimpleUserInfo(@RequestHeader String accessToken) { + Long userId = jwtTokenProvider.getUserPk(accessToken); + return ApiResponse.success(userService.getSimpleUserInfo(userId), ResponseCode.USER_CREATE_SUCCESS.getMessage()); + } + + // 회원정보 조회 + @Operation(summary = "[프로필] 회원 정보 조회", description = "회원 정보를 조회합니다.") + @GetMapping("/info") + public ApiResponse getUserInfo(@RequestHeader String accessToken) { + Long userId = jwtTokenProvider.getUserPk(accessToken); + return ApiResponse.success(userService.getUserInfo(userId), ResponseCode.USER_CREATE_SUCCESS.getMessage()); + } + + // 회원정보 수정 + @Operation(summary = "[프로필] 회원 정보 수정", description = "회원 정보를 수정합니다.") + @PutMapping("/update") + public ApiResponse updateUserInfo(@RequestBody @Valid UpdateUserDto updateUserDto) { + userService.updateUserInfo(updateUserDto); + return ApiResponse.success(null, ResponseCode.USER_UPDATE_SUCCESS.getMessage()); + } + + // 회원 기준섭취량 조회 + @Operation(summary = "[프로필] 회원 기준섭취량 조회", description = "회원 기준섭취량을 조회합니다.") + @GetMapping("/info/nutrition") + public ApiResponse getUserNutrition(@RequestHeader String accessToken) { + Long userId = jwtTokenProvider.getUserPk(accessToken); + return ApiResponse.success(userService.getUserNutrition(userId), ResponseCode.USER_READ_SUCCESS.getMessage()); + } + + // 회원 기준섭취량 직접 수정 + @Operation(summary = "[프로필] 회원 기준섭취량 직접 수정", description = "회원 기준섭취량을 직접 수정합니다.") + @PutMapping("/info/nutrition") + public ApiResponse updateUserNutrition(@RequestBody @Valid UpdateUserNutritionDto updateUserNutritionDto) { + userService.updateBaseNutrition(updateUserNutritionDto); + return ApiResponse.success(null, ResponseCode.USER_UPDATE_SUCCESS.getMessage()); + } + + // 회원의 친구 검색 결과 조회 + @Operation(summary = "[주간 랭킹] 회원의 친구 검색 결과 조회", description = "회원의 친구 검색 결과를 조회합니다.") + @PostMapping("/search") + public ApiResponse> searchUser(@RequestBody @Valid SearchUserDto searchUserDto) { + return ApiResponse.success(userService.searchUser(searchUserDto.getUserId(), searchUserDto.getInputName()), ResponseCode.USER_SEARCH_SUCCESS.getMessage()); + } + + // 회원이 특정 회원 팔로우 + @Operation(summary = "[주간 랭킹] 회원이 특정 회원 팔로우", description = "회원이 특정 회원을 팔로우합니다.") + @PostMapping("/follow/{toUserId}") + public ApiResponse followUser(@RequestHeader String accessToken, @PathVariable Long toUserId) { + Long userId = jwtTokenProvider.getUserPk(accessToken); + userService.followUser(userId, toUserId); + return ApiResponse.success(null, ResponseCode.USER_FOLLOW_SUCCESS.getMessage()); + } + + // 회원이 특정 회원 팔로우 취소 + @Operation(summary = "[주간 랭킹] 회원이 특정 회원 팔로우 취소", description = "회원이 특정 회원을 팔로우 취소합니다.") + @DeleteMapping("/follow/{toUserId}") + public ApiResponse unfollowUser(@RequestHeader String accessToken, @PathVariable Long toUserId) { + Long userId = jwtTokenProvider.getUserPk(accessToken); + userService.unfollowUser(userId, toUserId); + return ApiResponse.success(null, ResponseCode.USER_UNFOLLOW_SUCCESS.getMessage()); + } +} diff --git a/src/main/java/com/diareat/diareat/user/domain/BaseNutrition.java b/src/main/java/com/diareat/diareat/user/domain/BaseNutrition.java new file mode 100644 index 0000000..ad0706f --- /dev/null +++ b/src/main/java/com/diareat/diareat/user/domain/BaseNutrition.java @@ -0,0 +1,28 @@ +package com.diareat.diareat.user.domain; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.persistence.Embeddable; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Embeddable // 값 타입 +public class BaseNutrition { // 기본 영양소 4종만 포함 + + private int kcal = 0; // 칼로리 + private int carbohydrate = 0; // 탄수화물 + private int protein = 0; // 단백질 + private int fat = 0; // 지방 + + // 생성 메서드 + public static BaseNutrition createNutrition(int kcal, int carbohydrate, int protein, int fat) { + BaseNutrition baseNutrition = new BaseNutrition(); + baseNutrition.kcal = kcal; + baseNutrition.carbohydrate = carbohydrate; + baseNutrition.protein = protein; + baseNutrition.fat = fat; + return baseNutrition; + } +} diff --git a/src/main/java/com/diareat/diareat/user/domain/Follow.java b/src/main/java/com/diareat/diareat/user/domain/Follow.java new file mode 100644 index 0000000..991834e --- /dev/null +++ b/src/main/java/com/diareat/diareat/user/domain/Follow.java @@ -0,0 +1,37 @@ +package com.diareat.diareat.user.domain; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.persistence.*; +import java.io.Serializable; + +@NoArgsConstructor +@IdClass(Follow.PK.class) // 복합키를 위한 어노테이션 +@Getter +@Table(uniqueConstraints = { + @UniqueConstraint(columnNames = {"to_user", "from_user"}) +}) // 중복 팔로우 방지 +@Entity +public class Follow { + + @Id + @Column(name = "to_user", insertable = false, updatable = false) + private Long toUser; + + @Id + @Column(name = "from_user", insertable = false, updatable = false) + private Long fromUser; + + public static Follow makeFollow(Long toUser, Long fromUser) { + Follow follow = new Follow(); + follow.toUser = toUser; + follow.fromUser = fromUser; + return follow; + } + + public static class PK implements Serializable { // 복합키를 위한 클래스 + Long toUser; + Long fromUser; + } +} diff --git a/src/main/java/com/diareat/diareat/user/domain/User.java b/src/main/java/com/diareat/diareat/user/domain/User.java new file mode 100644 index 0000000..32d2c1b --- /dev/null +++ b/src/main/java/com/diareat/diareat/user/domain/User.java @@ -0,0 +1,139 @@ +package com.diareat.diareat.user.domain; + +import com.diareat.diareat.food.domain.FavoriteFood; +import com.diareat.diareat.food.domain.Food; +import com.diareat.diareat.util.UserTypeUtil; +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.*; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import reactor.util.annotation.Nullable; + +import javax.persistence.*; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class User implements UserDetails { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_id") + private Long id; + + private String name; // 닉네임 + + @JsonIgnore + @Column(length = 100, nullable = false, unique = true) + private String keyCode; // 로그인 식별키 + + @Nullable + private String image; // 프로필 사진 경로 + + private int height; // 키 + + private int weight; // 몸무게 + + private int gender; // 성별 (0: 남자, 1: 여자) + + private int age; // 나이 + + private int type; // 성별과 연령에 따른 유저 타입 (1~12) + + private LocalDateTime createdTime; // 회원가입 시간 + + private BaseNutrition baseNutrition; // 기준영양소 + + @OneToMany(mappedBy = "user", cascade = {CascadeType.PERSIST, CascadeType.REMOVE}) // 유저가 탈퇴하면 촬영한 음식도 삭제 + private List foods = new ArrayList<>(); + + @OneToMany(mappedBy = "user", cascade = {CascadeType.PERSIST, CascadeType.REMOVE}) // 유저가 탈퇴하면 즐겨찾기 음식도 삭제 + private List favoriteFoods = new ArrayList<>(); + + // Jwt 전용 설정 (UserDetails 인터페이스 구현) + + @ElementCollection(fetch = FetchType.EAGER) //roles 컬렉션 + private List roles = new ArrayList<>(); + + @Override //사용자의 권한 목록 리턴 + public Collection getAuthorities() { + return this.roles.stream() + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + } + + @Override + public String getUsername() { + return keyCode; + } + + @Override + public String getPassword() { + return null; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } + + // Jwt 전용 설정 종료 + + // 생성 메서드 + public static User createUser(String name, String image, String keyCode, int height, int weight, int gender, int age, BaseNutrition baseNutrition) { + User user = new User(); + user.name = name; + user.image = image; + user.keyCode = keyCode; + user.height = height; + user.weight = weight; + user.gender = gender; + user.age = age; + user.baseNutrition = baseNutrition; + user.type = UserTypeUtil.decideUserType(gender, age); + user.createdTime = LocalDateTime.now(); + return user; + } + + // 회원정보 수정 + public void updateUser(String name, int height, int weight, int age, int autoUpdate) { + this.name = name; + this.height = height; + this.weight = weight; + this.age = age; + if(autoUpdate == 1) { + this.type = UserTypeUtil.decideUserType(this.gender, this.age); + List standard = UserTypeUtil.getStanardByUserType(this.type); + this.baseNutrition = BaseNutrition.createNutrition(standard.get(0), standard.get(2), this.weight, standard.get(1)); + } + } + + // 회원 기준영양소 직접 수정 + public void updateBaseNutrition(BaseNutrition baseNutrition) { + this.baseNutrition = baseNutrition; + } + + public void setId(Long id) { // 테스트코드용 메서드 + this.id = id; + } +} diff --git a/src/main/java/com/diareat/diareat/user/dto/request/CreateUserDto.java b/src/main/java/com/diareat/diareat/user/dto/request/CreateUserDto.java new file mode 100644 index 0000000..0e45fde --- /dev/null +++ b/src/main/java/com/diareat/diareat/user/dto/request/CreateUserDto.java @@ -0,0 +1,23 @@ +package com.diareat.diareat.user.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class CreateUserDto { // JoinUserDto에서 검증 절차를 대행하기에 검증절차 생략 + + private String name; + private String image; + private String keyCode; + private int gender; + private int height; + private int weight; + private int age; + + public static CreateUserDto of(String name, String image, String keyCode, int gender, int height, int weight, int age){ + return new CreateUserDto(name, image, keyCode, gender, height, weight, age); + } +} diff --git a/src/main/java/com/diareat/diareat/user/dto/request/JoinUserDto.java b/src/main/java/com/diareat/diareat/user/dto/request/JoinUserDto.java new file mode 100644 index 0000000..fa35adc --- /dev/null +++ b/src/main/java/com/diareat/diareat/user/dto/request/JoinUserDto.java @@ -0,0 +1,33 @@ +package com.diareat.diareat.user.dto.request; + +import com.diareat.diareat.util.MessageUtil; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.validator.constraints.Range; + +import javax.validation.constraints.*; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class JoinUserDto { + + @NotBlank(message = MessageUtil.NOT_BLANK) + private String token; + + @NotBlank(message = MessageUtil.NOT_BLANK) + private String nickName; + + @Range(min = 0, max = 1, message = MessageUtil.GENDER_RANGE) + private int gender; + + @Range(min = 100, max = 250, message = MessageUtil.HEIGHT_RANGE) + private int height; + + @Range(min = 30, max = 150, message = MessageUtil.WEIGHT_RANGE) + private int weight; + + @Range(min = 5, max = 100, message = MessageUtil.AGE_RANGE) + private int age; +} diff --git a/src/main/java/com/diareat/diareat/user/dto/request/SearchUserDto.java b/src/main/java/com/diareat/diareat/user/dto/request/SearchUserDto.java new file mode 100644 index 0000000..86843d9 --- /dev/null +++ b/src/main/java/com/diareat/diareat/user/dto/request/SearchUserDto.java @@ -0,0 +1,25 @@ +package com.diareat.diareat.user.dto.request; + +import com.diareat.diareat.util.MessageUtil; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class SearchUserDto { + + @NotNull(message = MessageUtil.NOT_NULL) + private Long userId; + + @NotBlank(message = MessageUtil.NOT_BLANK) + private String inputName; + + public static SearchUserDto of(Long userId, String inputName) { + return new SearchUserDto(userId, inputName); + } +} diff --git a/src/main/java/com/diareat/diareat/user/dto/request/UpdateUserDto.java b/src/main/java/com/diareat/diareat/user/dto/request/UpdateUserDto.java new file mode 100644 index 0000000..8c75931 --- /dev/null +++ b/src/main/java/com/diareat/diareat/user/dto/request/UpdateUserDto.java @@ -0,0 +1,38 @@ +package com.diareat.diareat.user.dto.request; + +import com.diareat.diareat.util.MessageUtil; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.validator.constraints.Range; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class UpdateUserDto { + + @NotNull(message = MessageUtil.NOT_NULL) + private Long userId; + + @NotBlank(message = MessageUtil.NOT_BLANK) + private String name; + + @Range(min = 100, max = 250, message = MessageUtil.HEIGHT_RANGE) + private int height; + + @Range(min = 30, max = 200, message = MessageUtil.WEIGHT_RANGE) + private int weight; + + @Range(min = 5, max = 100, message = MessageUtil.AGE_RANGE) + private int age; + + @Range(min = 0, max = 1, message = MessageUtil.ZERO_OR_ONE) + private int autoUpdateNutrition; // 개인정보를 활용한 기준 영양소 자동계산 여부 (0, 1) + + public static UpdateUserDto of(Long userId, String userName, int userHeight, int userWeight, int userAge, int autoUpdateNutrition) { + return new UpdateUserDto(userId, userName, userHeight, userWeight, userAge, autoUpdateNutrition); + } +} diff --git a/src/main/java/com/diareat/diareat/user/dto/request/UpdateUserNutritionDto.java b/src/main/java/com/diareat/diareat/user/dto/request/UpdateUserNutritionDto.java new file mode 100644 index 0000000..aa3ad4c --- /dev/null +++ b/src/main/java/com/diareat/diareat/user/dto/request/UpdateUserNutritionDto.java @@ -0,0 +1,34 @@ +package com.diareat.diareat.user.dto.request; + +import com.diareat.diareat.util.MessageUtil; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.validator.constraints.Range; + +import javax.validation.constraints.NotNull; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class UpdateUserNutritionDto { + + @NotNull(message = MessageUtil.NOT_NULL) + private Long userId; + + @Range(min = 100, max = 10000, message = MessageUtil.CALORIE_RANGE) + private int calorie; + + @Range(min = 100, max = 500, message = MessageUtil.CARBOHYDRATE_RANGE) + private int carbohydrate; + + @Range(min = 25, max = 500, message = MessageUtil.PROTEIN_RANGE) + private int protein; + + @Range(min = 25, max = 500, message = MessageUtil.FAT_RANGE) + private int fat; + + public static UpdateUserNutritionDto of(Long userId, int calorie, int carbohydrate, int protein, int fat) { + return new UpdateUserNutritionDto(userId, calorie, carbohydrate, protein, fat); + } +} diff --git a/src/main/java/com/diareat/diareat/user/dto/response/ResponseRankUserDto.java b/src/main/java/com/diareat/diareat/user/dto/response/ResponseRankUserDto.java new file mode 100644 index 0000000..2c7940c --- /dev/null +++ b/src/main/java/com/diareat/diareat/user/dto/response/ResponseRankUserDto.java @@ -0,0 +1,37 @@ +package com.diareat.diareat.user.dto.response; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import lombok.Builder; +import lombok.Getter; + +import java.io.Serializable; + +@Getter +@Builder +@JsonSerialize +@JsonDeserialize +public class ResponseRankUserDto implements Serializable { + + private Long userId; + private String name; + private String image; + private double calorieScore; + private double carbohydrateScore; + private double proteinScore; + private double fatScore; + private double totalScore; + + public static ResponseRankUserDto of(Long userId, String name, String image, double calorieScore, double carbohydrateScore, double proteinScore, double fatScore, double totalScore) { + return ResponseRankUserDto.builder() + .userId(userId) + .name(name) + .image(image) + .calorieScore(calorieScore) + .carbohydrateScore(carbohydrateScore) + .proteinScore(proteinScore) + .fatScore(fatScore) + .totalScore(totalScore) + .build(); + } +} diff --git a/src/main/java/com/diareat/diareat/user/dto/response/ResponseSearchUserDto.java b/src/main/java/com/diareat/diareat/user/dto/response/ResponseSearchUserDto.java new file mode 100644 index 0000000..ab38198 --- /dev/null +++ b/src/main/java/com/diareat/diareat/user/dto/response/ResponseSearchUserDto.java @@ -0,0 +1,20 @@ +package com.diareat.diareat.user.dto.response; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import lombok.Builder; +import lombok.Getter; + +import java.io.Serializable; + +@Getter +@Builder +@JsonSerialize +@JsonDeserialize +public class ResponseSearchUserDto implements Serializable { + + private Long userId; + private String name; + private String image; + private boolean follow; // 유저가 이미 팔로우한 유저인지 확인 +} diff --git a/src/main/java/com/diareat/diareat/user/dto/response/ResponseSimpleUserDto.java b/src/main/java/com/diareat/diareat/user/dto/response/ResponseSimpleUserDto.java new file mode 100644 index 0000000..b60eb53 --- /dev/null +++ b/src/main/java/com/diareat/diareat/user/dto/response/ResponseSimpleUserDto.java @@ -0,0 +1,18 @@ +package com.diareat.diareat.user.dto.response; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import lombok.Builder; +import lombok.Getter; + +import java.io.Serializable; + +@Getter +@Builder +@JsonSerialize +@JsonDeserialize +public class ResponseSimpleUserDto implements Serializable { + + private String name; + private String image; +} diff --git a/src/main/java/com/diareat/diareat/user/dto/response/ResponseUserDto.java b/src/main/java/com/diareat/diareat/user/dto/response/ResponseUserDto.java new file mode 100644 index 0000000..a105340 --- /dev/null +++ b/src/main/java/com/diareat/diareat/user/dto/response/ResponseUserDto.java @@ -0,0 +1,20 @@ +package com.diareat.diareat.user.dto.response; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import lombok.Builder; +import lombok.Getter; + +import java.io.Serializable; + +@Getter +@Builder +@JsonSerialize +@JsonDeserialize +public class ResponseUserDto implements Serializable { + + private String name; + private int height; + private int weight; + private int age; +} diff --git a/src/main/java/com/diareat/diareat/user/dto/response/ResponseUserNutritionDto.java b/src/main/java/com/diareat/diareat/user/dto/response/ResponseUserNutritionDto.java new file mode 100644 index 0000000..95f742e --- /dev/null +++ b/src/main/java/com/diareat/diareat/user/dto/response/ResponseUserNutritionDto.java @@ -0,0 +1,20 @@ +package com.diareat.diareat.user.dto.response; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import lombok.Builder; +import lombok.Getter; + +import java.io.Serializable; + +@Getter +@Builder +@JsonSerialize +@JsonDeserialize +public class ResponseUserNutritionDto implements Serializable { + + private int calorie; // 유저가 설정한(할 수 있는) 현재 영양소 기준섭취량 + private int carbohydrate; + private int protein; + private int fat; +} diff --git a/src/main/java/com/diareat/diareat/user/repository/FollowRepository.java b/src/main/java/com/diareat/diareat/user/repository/FollowRepository.java new file mode 100644 index 0000000..46e04bc --- /dev/null +++ b/src/main/java/com/diareat/diareat/user/repository/FollowRepository.java @@ -0,0 +1,17 @@ +package com.diareat.diareat.user.repository; + +import com.diareat.diareat.user.domain.Follow; +import com.diareat.diareat.user.domain.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface FollowRepository extends JpaRepository { + @Query(value = "select u from Follow f INNER JOIN User u ON f.toUser = u.id where f.fromUser = :userId") // 팔로우 목록 조회 + List findAllByFromUser(@Param("userId") Long userId); + + boolean existsByFromUserAndToUser(Long fromUser, Long toUser); // 팔로우 여부 확인 + void deleteByFromUserAndToUser(Long fromUser, Long toUser); // 팔로우 취소 +} diff --git a/src/main/java/com/diareat/diareat/user/repository/UserRepository.java b/src/main/java/com/diareat/diareat/user/repository/UserRepository.java new file mode 100644 index 0000000..d9eac11 --- /dev/null +++ b/src/main/java/com/diareat/diareat/user/repository/UserRepository.java @@ -0,0 +1,16 @@ +package com.diareat.diareat.user.repository; + +import com.diareat.diareat.user.domain.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + + List findAllByNameContaining(String name); // 회원이 팔로우를 위해 검색한 유저 목록 조회 + + boolean existsByName(String name); // 회원가입 시 닉네임 중복 확인 + boolean existsByKeyCode(String keyCode); // 카카오 회원가입 시 중복 확인 + Optional findByKeyCode(String keyCode); // 카카오 회원가입 시 중복 확인 +} diff --git a/src/main/java/com/diareat/diareat/user/service/UserService.java b/src/main/java/com/diareat/diareat/user/service/UserService.java new file mode 100644 index 0000000..965e9c7 --- /dev/null +++ b/src/main/java/com/diareat/diareat/user/service/UserService.java @@ -0,0 +1,186 @@ +package com.diareat.diareat.user.service; + +import com.diareat.diareat.user.domain.BaseNutrition; +import com.diareat.diareat.user.domain.Follow; +import com.diareat.diareat.user.domain.User; +import com.diareat.diareat.user.dto.request.CreateUserDto; +import com.diareat.diareat.user.dto.request.UpdateUserDto; +import com.diareat.diareat.user.dto.request.UpdateUserNutritionDto; +import com.diareat.diareat.user.dto.response.ResponseSearchUserDto; +import com.diareat.diareat.user.dto.response.ResponseSimpleUserDto; +import com.diareat.diareat.user.dto.response.ResponseUserDto; +import com.diareat.diareat.user.dto.response.ResponseUserNutritionDto; +import com.diareat.diareat.user.repository.FollowRepository; +import com.diareat.diareat.user.repository.UserRepository; +import com.diareat.diareat.util.UserTypeUtil; +import com.diareat.diareat.util.api.ResponseCode; +import com.diareat.diareat.util.exception.UserException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@RequiredArgsConstructor +@Service +public class UserService { + + private final UserRepository userRepository; + private final FollowRepository followRepository; + + // 회원정보 저장 + @Transactional + public Long saveUser(CreateUserDto createUserDto) { + if (userRepository.existsByName(createUserDto.getName())) { + log.info("이미 존재하는 닉네임입니다 by {}", createUserDto.getName()); + throw new UserException(ResponseCode.USER_NAME_ALREADY_EXIST); + } + if (userRepository.existsByKeyCode(createUserDto.getKeyCode())) { + log.info("이미 존재하는 " + + "con키코드입니다 by {}", createUserDto.getKeyCode()); + throw new UserException(ResponseCode.USER_ALREADY_EXIST); + } + + int type = UserTypeUtil.decideUserType(createUserDto.getGender(), createUserDto.getAge()); + log.info("회원 타입: {}", type); + List standard = UserTypeUtil.getStanardByUserType(type); // 유저 타입에 따른 기본 기준섭취량 조회 + BaseNutrition baseNutrition = BaseNutrition.createNutrition(standard.get(0), standard.get(2), createUserDto.getWeight(), standard.get(1)); // 단백질은 자신 체중 기준으로 계산 + User user = User.createUser(createUserDto.getName(), createUserDto.getImage(), createUserDto.getKeyCode(), createUserDto.getHeight(), createUserDto.getWeight(), createUserDto.getGender(), createUserDto.getAge(), baseNutrition); + return userRepository.save(user).getId(); + } + + // 회원 기본정보 조회 + // @Cacheable(value = "ResponseSimpleUserDto", key = "#userId", cacheManager = "diareatCacheManager") + @Transactional(readOnly = true) + public ResponseSimpleUserDto getSimpleUserInfo(Long userId) { + User user = getUserById(userId); + log.info("{} 회원 기본정보 조회 완료: ", user.getName()); + return ResponseSimpleUserDto.builder() + .name(user.getName()) + .image(user.getImage()) + .build(); + } + + // 회원정보 조회 + // @Cacheable(value = "ResponseUserDto", key = "#userId", cacheManager = "diareatCacheManager") + @Transactional(readOnly = true) + public ResponseUserDto getUserInfo(Long userId) { + User user = getUserById(userId); + log.info("{} 회원정보 조회 완료: ", user.getName()); + return ResponseUserDto.builder() + .name(user.getName()) + .height(user.getHeight()) + .weight(user.getWeight()) + .age(user.getAge()) + .build(); + } + + // 회원정보 수정 + // @CacheEvict(value = {"ResponseSimpleUserDto", "ResponseUserDto"}, key = "#updateUserDto.getUserId()", cacheManager = "diareatCacheManager") + @Transactional + public void updateUserInfo(UpdateUserDto updateUserDto) { + User user = getUserById(updateUserDto.getUserId()); + log.info("{} 회원정보 조회 완료: ", user.getName()); + user.updateUser(updateUserDto.getName(), updateUserDto.getHeight(), updateUserDto.getWeight(), updateUserDto.getAge(), updateUserDto.getAutoUpdateNutrition()); + userRepository.save(user); + log.info("{} 회원정보 수정 완료: ", user.getName()); + } + + // 회원 기준섭취량 조회 + // @Cacheable(value = "ResponseUserNutritionDto", key = "#userId", cacheManager = "diareatCacheManager") + @Transactional(readOnly = true) + public ResponseUserNutritionDto getUserNutrition(Long userId) { + User user = getUserById(userId); + log.info("{} user 객체 조회 완료: ", user.getName()); + return ResponseUserNutritionDto.builder() + .calorie(user.getBaseNutrition().getKcal()) + .carbohydrate(user.getBaseNutrition().getCarbohydrate()) + .protein(user.getBaseNutrition().getProtein()) + .fat(user.getBaseNutrition().getFat()) + .build(); + } + + // 회원 기준섭취량 직접 수정 + // @CacheEvict(value = "ResponseUserNutritionDto", key = "#updateUserNutritionDto.getUserId()", cacheManager = "diareatCacheManager") + @Transactional + public void updateBaseNutrition(UpdateUserNutritionDto updateUserNutritionDto) { + User user = getUserById(updateUserNutritionDto.getUserId()); + log.info("{} user 객체 조회 완료: ", user.getName()); + BaseNutrition baseNutrition = BaseNutrition.createNutrition(updateUserNutritionDto.getCalorie(), updateUserNutritionDto.getCarbohydrate(), updateUserNutritionDto.getProtein(), updateUserNutritionDto.getFat()); + user.updateBaseNutrition(baseNutrition); + userRepository.save(user); + log.info("{} 회원 기준섭취량 수정 완료: ", user.getName()); + } + + // 회원 탈퇴 + // @CacheEvict(value = {"ResponseSimpleUserDto", "ResponseUserDto", "ResponseUserNutritionDto"}, key = "#userId", cacheManager = "diareatCacheManager") + @Transactional + public void deleteUser(Long userId) { + validateUser(userId); + userRepository.deleteById(userId); + log.info("PK {} 회원 탈퇴 완료: ", userId); + } + + // 회원의 친구 검색 결과 조회 -> 검색 및 팔로우는 굉장히 돌발적으로 이루어질 가능성이 높아 캐시 적용 X + @Transactional(readOnly = true) + public List searchUser(Long hostId, String name) { + validateUser(hostId); + log.info("{} 회원 검증 완료", hostId); + List users = new ArrayList<>(userRepository.findAllByNameContaining(name)); + log.info("{} 검색 결과 조회 완료", name); + users.removeIf(user -> user.getId().equals(hostId)); // 검색 결과에서 자기 자신은 제외 (removeIf 메서드는 ArrayList에만 존재) + return users.stream() + .map(user -> ResponseSearchUserDto.builder() + .userId(user.getId()) + .name(user.getName()) + .image(user.getImage()) + .follow(followRepository.existsByFromUserAndToUser(hostId, user.getId())) + .build()).collect(Collectors.toList()); + } + + // 회원이 특정 회원 팔로우 + @Transactional + public void followUser(Long fromId, Long toId) { + validateUser(fromId); + validateUser(toId); + log.info("팔로우 대상 검증 완료"); + // 이미 팔로우 중인 경우 + if (followRepository.existsByFromUserAndToUser(fromId, toId)) { + log.info("{}는 이미 {}를 팔로우한 상태입니다.", fromId, toId); + throw new UserException(ResponseCode.FOLLOWED_ALREADY); + } + followRepository.save(Follow.makeFollow(toId, fromId)); + log.info("이제 {}가 {}를 팔로우합니다.", fromId, toId); + } + + // 회원이 특정 회원 팔로우 취소 + @Transactional + public void unfollowUser(Long fromId, Long toId) { + validateUser(fromId); + validateUser(toId); + log.info("팔로우 대상 검증 완료"); + // 이미 팔로우 취소한 경우 + if (!followRepository.existsByFromUserAndToUser(fromId, toId)) { + log.info("{}는 이미 {}를 팔로우 취소한 상태입니다.", fromId, toId); + throw new UserException(ResponseCode.UNFOLLOWED_ALREADY); + } + followRepository.deleteByFromUserAndToUser(fromId, toId); + log.info("이제 {}가 {}를 언팔로우합니다.", fromId, toId); + } + + private void validateUser(Long userId) { + if (!userRepository.existsById(userId)) + throw new UserException(ResponseCode.USER_NOT_FOUND); + } + + private User getUserById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new UserException(ResponseCode.USER_NOT_FOUND)); + } +} diff --git a/src/main/java/com/diareat/diareat/util/MessageUtil.java b/src/main/java/com/diareat/diareat/util/MessageUtil.java new file mode 100644 index 0000000..ba4d77b --- /dev/null +++ b/src/main/java/com/diareat/diareat/util/MessageUtil.java @@ -0,0 +1,25 @@ +package com.diareat.diareat.util; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class MessageUtil { // 반복되는 메시지의 형식을 저장하고 관리 + + public static final String NOT_NULL = "값이 존재해야 합니다."; + public static final String NOT_BLANK = "값이 비어있을 수 없습니다."; + + public static final String HEIGHT_RANGE = "키는 100 이상, 250 이하의 값을 입력해주세요."; + public static final String WEIGHT_RANGE = "몸무게는 30 이상, 200 이하의 값을 입력해주세요."; + public static final String AGE_RANGE = "나이는 5 이상, 100 이하의 값을 입력해주세요."; + public static final String GENDER_RANGE = "성별은 0(남자) 또는 1(여자)의 값을 입력해주세요."; + + public static final String CALORIE_RANGE = "칼로리는 100 이상, 10000 이하의 값을 입력해주세요."; + public static final String CARBOHYDRATE_RANGE = "탄수화물은 100 이상, 500 이하의 값을 입력해주세요."; + public static final String PROTEIN_RANGE = "단백질은 25 이상, 500 이하의 값을 입력해주세요."; + public static final String FAT_RANGE = "지방은 25 이상, 500 이하의 값을 입력해주세요."; + public static final String ZERO_OR_ONE = "0 또는 1의 값을 입력해주세요."; + + public static final String PAST_OR_PRESENT = "과거 또는 오늘 날짜여야 합니다."; + public static final String TIME_STAMP = "addedTime"; +} diff --git a/src/main/java/com/diareat/diareat/util/UserTypeUtil.java b/src/main/java/com/diareat/diareat/util/UserTypeUtil.java new file mode 100644 index 0000000..d2e4332 --- /dev/null +++ b/src/main/java/com/diareat/diareat/util/UserTypeUtil.java @@ -0,0 +1,109 @@ +package com.diareat.diareat.util; + +import java.util.ArrayList; +import java.util.List; + +public class UserTypeUtil { + + public static int decideUserType(int gender, int age){ // 성별과 연령에 따른 유저 타입 분류 + if(gender == 0){ // 남자 + if(age < 11) return 1; + else if(age < 19) return 2; + else if(age < 30) return 3; + else if(age < 50) return 4; + else if(age < 65) return 5; + else return 6; + } else { // 여자 + if(age < 11) return 7; + else if(age < 19) return 8; + else if(age < 30) return 9; + else if(age < 50) return 10; + else if(age < 65) return 11; + else return 12; + } + } + + public static List getStanardByUserType(int type){ + ArrayList standard = new ArrayList<>(); + // 칼, 지, 탄의 순서로 3개 저장 + + /** 기준섭취량 산정을 위한 type + * 1. 11세 미만 남성 + * 2. 12~18세 남성 + * 3. 19~29세 남성 + * 4. 30~49세 남성 + * 5. 50~64세 남성 + * 6. 65세 이상 남성 + * 7. 11세 미만 여성 + * 8. 12~18세 여성 + * 9. 19~29세 여성 + * 10. 30~49세 여성 + * 11. 50~64세 여성 + * 12. 65세 이상 여성 + */ + + switch(type){ + case 1: // 남자 + standard.add(1630); // 칼로리 + standard.add(45); // 지방 + standard.add(240); // 탄수화물 + break; + case 2: + standard.add(1950); + standard.add(60); + standard.add(280); + break; + case 3: + standard.add(2070); + standard.add(60); + standard.add(270); + break; + case 4: + standard.add(2180); + standard.add(60); + standard.add(280); + break; + case 5: + standard.add(1980); + standard.add(50); + standard.add(280); + break; + case 6: + standard.add(1610); + standard.add(30); + standard.add(260); + break; + case 7: // 여자 + standard.add(1430); + standard.add(40); + standard.add(210); + break; + case 8: + standard.add(1480); + standard.add(40); + standard.add(215); + break; + case 9: + standard.add(1470); + standard.add(45); + standard.add(200); + break; + case 10: + standard.add(1500); + standard.add(40); + standard.add(210); + break; + case 11: + standard.add(1430); + standard.add(35); + standard.add(225); + break; + default: // 12 + standard.add(1230); + standard.add(20); + standard.add(210); + break; + } + return standard; + } +} diff --git a/src/main/java/com/diareat/diareat/util/api/ApiHeader.java b/src/main/java/com/diareat/diareat/util/api/ApiHeader.java new file mode 100644 index 0000000..7788e9a --- /dev/null +++ b/src/main/java/com/diareat/diareat/util/api/ApiHeader.java @@ -0,0 +1,20 @@ +package com.diareat.diareat.util.api; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ApiHeader { + + private int code; + private String message; + + public int getCode() { + return code; + } + + public String getMessage() { + return message; + } +} diff --git a/src/main/java/com/diareat/diareat/util/api/ApiResponse.java b/src/main/java/com/diareat/diareat/util/api/ApiResponse.java new file mode 100644 index 0000000..1f5c4d9 --- /dev/null +++ b/src/main/java/com/diareat/diareat/util/api/ApiResponse.java @@ -0,0 +1,29 @@ +package com.diareat.diareat.util.api; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class ApiResponse { + + private ApiHeader header; + private T data; + private String msg; + + private static final int SUCCESS = 200; + + private ApiResponse(ApiHeader header, T data, String msg) { + this.header = header; + this.data = data; + this.msg = msg; + } + + public static ApiResponse success(T data, String message) { + return new ApiResponse(new ApiHeader(SUCCESS, "SUCCESS"), data, message); + } + + public static ApiResponse fail(ResponseCode responseCode, T data) { + return new ApiResponse(new ApiHeader(responseCode.getHttpStatusCode(), responseCode.getMessage()), data, responseCode.getMessage()); + } +} \ No newline at end of file diff --git a/src/main/java/com/diareat/diareat/util/api/ResponseCode.java b/src/main/java/com/diareat/diareat/util/api/ResponseCode.java new file mode 100644 index 0000000..5943e1d --- /dev/null +++ b/src/main/java/com/diareat/diareat/util/api/ResponseCode.java @@ -0,0 +1,76 @@ +package com.diareat.diareat.util.api; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +public enum ResponseCode { + + // 400 Bad Request + BAD_REQUEST(HttpStatus.BAD_REQUEST, false, "잘못된 요청입니다."), + + // 401 Unauthorized + TOKEN_VALIDATION_FAILURE(HttpStatus.UNAUTHORIZED, false, "토큰 검증 실패"), + REFRESH_TOKEN_VALIDATION_FAILURE(HttpStatus.UNAUTHORIZED, false, "Refresh 토큰 검증 실패"), + + // 403 Forbidden + FORBIDDEN(HttpStatus.FORBIDDEN, false, "권한이 없습니다."), + NOT_FOOD_OWNER(HttpStatus.FORBIDDEN, false, "음식의 주인 유저가 아닙니다."), + + // 404 Not Found + USER_NOT_FOUND(HttpStatus.NOT_FOUND, false, "사용자를 찾을 수 없습니다."), + FOOD_NOT_FOUND(HttpStatus.NOT_FOUND, false, "음식을 찾을 수 없습니다."), + FAVORITE_NOT_FOUND(HttpStatus.NOT_FOUND, false, "즐겨찾기를 찾을 수 없습니다."), + + // 405 Method Not Allowed + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, false, "허용되지 않은 메소드입니다."), + + // 409 Conflict + USER_ALREADY_EXIST(HttpStatus.CONFLICT, false, "이미 가입한 사용자입니다."), + USER_NAME_ALREADY_EXIST(HttpStatus.CONFLICT, false, "이미 존재하는 닉네임입니다."), + FOLLOWED_ALREADY(HttpStatus.CONFLICT, false, "이미 팔로우한 사용자입니다."), + UNFOLLOWED_ALREADY(HttpStatus.CONFLICT, false, "이미 언팔로우한 사용자입니다."), + FAVORITE_ALREADY_EXIST(HttpStatus.CONFLICT, false, "이미 즐겨찾기에 존재하는 음식입니다."), + FOOD_NAME_ALREADY_EXIST(HttpStatus.CONFLICT, false, "이미 존재하는 음식 이름입니다."), + + // 500 Internal Server Error + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, false, "서버에 오류가 발생하였습니다."), + + // 200 OK + USER_READ_SUCCESS(HttpStatus.CREATED, true, "사용자 정보 조회 성공"), + USER_UPDATE_SUCCESS(HttpStatus.OK, true, "사용자 정보 수정 성공"), + USER_SEARCH_SUCCESS(HttpStatus.OK, true, "사용자 검색 성공"), + USER_FOLLOW_SUCCESS(HttpStatus.OK, true, "사용자 팔로우 성공"), + USER_UNFOLLOW_SUCCESS(HttpStatus.OK, true, "사용자 언팔로우 성공"), + USER_LOGIN_SUCCESS(HttpStatus.OK, true, "사용자 로그인 성공"), + + FOOD_READ_SUCCESS(HttpStatus.OK, true, "음식 정보 조회 성공"), + FOOD_UPDATE_SUCCESS(HttpStatus.OK, true, "음식 정보 수정 성공"), + FOOD_DELETE_SUCCESS(HttpStatus.OK, true, "음식 정보 삭제 성공"), + FOOD_FAVORITE_READ_SUCCESS(HttpStatus.OK, true, "즐겨찾기 음식 조회 성공"), + FOOD_FAVORITE_UPDATE_SUCCESS(HttpStatus.OK, true, "즐겨찾기 음식 수정 성공"), + FOOD_FAVORITE_DELETE_SUCCESS(HttpStatus.OK, true, "즐겨찾기 음식 삭제 성공"), + + FOOD_RANK_READ_SUCCESS(HttpStatus.OK, true, "식습관 점수 기반 랭킹 조회 성공"), + + TOKEN_CHECK_SUCCESS(HttpStatus.OK, true, "토큰 검증 완료"), + TOKEN_REISSUE_SUCCESS(HttpStatus.OK, true, "토큰 재발급 완료"), + + + // 201 Created + USER_CREATE_SUCCESS(HttpStatus.CREATED, true, "사용자 생성 성공"), + FOOD_CREATE_SUCCESS(HttpStatus.CREATED, true, "음식 생성 성공"), + FOOD_FAVORITE_CREATE_SUCCESS(HttpStatus.OK, true, "즐겨찾기 음식 생성 성공"); + + + private final HttpStatus httpStatus; + private final Boolean success; + private final String message; + + public int getHttpStatusCode() { + return httpStatus.value(); + } +} diff --git a/src/main/java/com/diareat/diareat/util/exception/BaseException.java b/src/main/java/com/diareat/diareat/util/exception/BaseException.java new file mode 100644 index 0000000..fade27c --- /dev/null +++ b/src/main/java/com/diareat/diareat/util/exception/BaseException.java @@ -0,0 +1,17 @@ +package com.diareat.diareat.util.exception; + +import com.diareat.diareat.util.api.ResponseCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class BaseException extends RuntimeException { + + private final ResponseCode responseCode; + + @Override + public String getMessage() { + return responseCode.getMessage(); + } +} diff --git a/src/main/java/com/diareat/diareat/util/exception/FavoriteException.java b/src/main/java/com/diareat/diareat/util/exception/FavoriteException.java new file mode 100644 index 0000000..7b66855 --- /dev/null +++ b/src/main/java/com/diareat/diareat/util/exception/FavoriteException.java @@ -0,0 +1,9 @@ +package com.diareat.diareat.util.exception; + +import com.diareat.diareat.util.api.ResponseCode; + +public class FavoriteException extends BaseException{ + public FavoriteException(ResponseCode responseCode) { + super(responseCode); + } +} diff --git a/src/main/java/com/diareat/diareat/util/exception/FoodException.java b/src/main/java/com/diareat/diareat/util/exception/FoodException.java new file mode 100644 index 0000000..3291c75 --- /dev/null +++ b/src/main/java/com/diareat/diareat/util/exception/FoodException.java @@ -0,0 +1,9 @@ +package com.diareat.diareat.util.exception; + +import com.diareat.diareat.util.api.ResponseCode; + +public class FoodException extends BaseException { + public FoodException(ResponseCode responseCode) { + super(responseCode); + } +} diff --git a/src/main/java/com/diareat/diareat/util/exception/GlobalExceptionHandler.java b/src/main/java/com/diareat/diareat/util/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..56f64f1 --- /dev/null +++ b/src/main/java/com/diareat/diareat/util/exception/GlobalExceptionHandler.java @@ -0,0 +1,56 @@ +package com.diareat.diareat.util.exception; + +import com.diareat.diareat.util.api.ApiResponse; +import com.diareat.diareat.util.api.ResponseCode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(UserException.class) + public ApiResponse handleUserException(UserException e) { + log.info("UserException: {}", e.getMessage()); + return ApiResponse.fail(e.getResponseCode(), null); + } + + @ExceptionHandler(FavoriteException.class) + public ApiResponse handleFavoriteException(FavoriteException e) { + log.info("FavoriteException: {}", e.getMessage()); + return ApiResponse.fail(e.getResponseCode(), null); + } + + @ExceptionHandler(FoodException.class) + public ApiResponse handleFoodException(FoodException e) { + log.info("FoodException: {}", e.getMessage()); + return ApiResponse.fail(e.getResponseCode(), null); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) // 요청의 유효성 검사 실패 시 + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse> handleInValidRequestException(MethodArgumentNotValidException e) { + // 에러가 발생한 객체 내 필드와 대응하는 에러 메시지를 map에 저장하여 반환 + Map errors = new HashMap<>(); + e.getBindingResult().getFieldErrors().forEach(error -> { + String fieldName = error.getField(); + String errorMessage = error.getDefaultMessage(); + errors.put(fieldName, errorMessage); + }); + return ApiResponse.fail(ResponseCode.BAD_REQUEST, errors); + } + + @ExceptionHandler(ValidException.class) // jwt 토큰 만료 관련 예외처리 + @ResponseStatus(HttpStatus.UNAUTHORIZED) + public ApiResponse handleValidException(ValidException e) { + log.info("Invalid Jwt Token: {}", e.getMessage()); + return ApiResponse.fail(e.getResponseCode(), null); + } +} diff --git a/src/main/java/com/diareat/diareat/util/exception/UserException.java b/src/main/java/com/diareat/diareat/util/exception/UserException.java new file mode 100644 index 0000000..a6d58bb --- /dev/null +++ b/src/main/java/com/diareat/diareat/util/exception/UserException.java @@ -0,0 +1,9 @@ +package com.diareat.diareat.util.exception; + +import com.diareat.diareat.util.api.ResponseCode; + +public class UserException extends BaseException { + public UserException(ResponseCode responseCode) { + super(responseCode); + } +} diff --git a/src/main/java/com/diareat/diareat/util/exception/ValidException.java b/src/main/java/com/diareat/diareat/util/exception/ValidException.java new file mode 100644 index 0000000..df30754 --- /dev/null +++ b/src/main/java/com/diareat/diareat/util/exception/ValidException.java @@ -0,0 +1,9 @@ +package com.diareat.diareat.util.exception; + +import com.diareat.diareat.util.api.ResponseCode; + +public class ValidException extends BaseException { + public ValidException(ResponseCode responseCode) { + super(responseCode); + } +} diff --git a/src/main/resources/application-db.properties b/src/main/resources/application-db.properties new file mode 100644 index 0000000..50a99ea --- /dev/null +++ b/src/main/resources/application-db.properties @@ -0,0 +1,14 @@ +spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver +spring.jpa.hibernate.ddl-auto=update + +# Remote DB +spring.datasource.url=${DB_ENDPOINT} +spring.datasource.username=${DB_USERNAME} +spring.datasource.password=${DB_PASSWORD} + +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5Dialect +spring.jpa.properties.hibernate.format_sql=true + +# JWT +jwt.secret=${JWT_KEY} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 8b13789..de6bc9e 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,12 @@ +# db +spring.profiles.include = db +# swagger +spring.mvc.pathmatch.matching-strategy=ant_path_matcher + +# redis +spring.redis.serializer=org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer +spring.cache.type=redis +spring.cache.redis.cache-null-values=true +spring.redis.host=${REDIS_ENDPOINT} +spring.redis.port=6379 diff --git a/src/test/java/com/diareat/diareat/DiareatApplicationTests.java b/src/test/java/com/diareat/diareat/DiareatApplicationTests.java index 222773b..61b9333 100644 --- a/src/test/java/com/diareat/diareat/DiareatApplicationTests.java +++ b/src/test/java/com/diareat/diareat/DiareatApplicationTests.java @@ -1,9 +1,9 @@ package com.diareat.diareat; import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.web.WebAppConfiguration; -@SpringBootTest +@WebAppConfiguration // 현재 API가 존재하고 있는 웹 어플리케이션이므로 WebAppConfiguration 어노테이션을 붙여주어야 class DiareatApplicationTests { @Test diff --git a/src/test/java/com/diareat/diareat/config/TestConfig.java b/src/test/java/com/diareat/diareat/config/TestConfig.java new file mode 100644 index 0000000..d90bdbc --- /dev/null +++ b/src/test/java/com/diareat/diareat/config/TestConfig.java @@ -0,0 +1,19 @@ +package com.diareat.diareat.config; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.SecurityFilterChain; + +@TestConfiguration +public class TestConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf().disable() + .authorizeRequests() + .anyRequest().permitAll(); + return http.build(); + } +} diff --git a/src/test/java/com/diareat/diareat/controller/FoodControllerTest.java b/src/test/java/com/diareat/diareat/controller/FoodControllerTest.java new file mode 100644 index 0000000..992a01e --- /dev/null +++ b/src/test/java/com/diareat/diareat/controller/FoodControllerTest.java @@ -0,0 +1,537 @@ +package com.diareat.diareat.controller; + +import com.diareat.diareat.auth.component.JwtTokenProvider; +import com.diareat.diareat.food.controller.FoodController; +import com.diareat.diareat.food.domain.FavoriteFood; +import com.diareat.diareat.food.domain.Food; +import com.diareat.diareat.food.dto.*; +import com.diareat.diareat.food.service.FoodService; +import com.diareat.diareat.user.domain.BaseNutrition; +import com.diareat.diareat.user.domain.User; +import com.diareat.diareat.user.dto.response.ResponseRankUserDto; +import com.diareat.diareat.util.api.ApiResponse; +import com.diareat.diareat.util.api.ResponseCode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.hibernate.validator.internal.util.privilegedactions.LoadClass; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.cglib.core.Local; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import java.lang.reflect.Field; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@WebMvcTest(controllers = FoodController.class) +class FoodControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private WebApplicationContext webApplicationContext; + + @MockBean + private FoodService foodService; + + @MockBean + private JwtTokenProvider jwtTokenProvider; + + private final Long testUserId = 1L; + private final Long testFoodId = 2L; + private final Long testFavoriteFoodId = 3L; + private final ObjectMapper mapper = new ObjectMapper(); + + private final User testUser = User.createUser("test", "test", "test", 180, 70, 0, 20, BaseNutrition.createNutrition(2000, 300, 80, 80)); + private final Food testFood = Food.createFood("testFood", testUser, BaseNutrition.createNutrition(500, 50, 30, 10), 2021, 10, 10); + private final FavoriteFood testFavoriteFood = FavoriteFood.createFavoriteFood("testFavoriteFood", testUser, testFood, BaseNutrition.createNutrition(500, 50, 30, 10)); + private final BaseNutrition testBaseNutrition = BaseNutrition.createNutrition(500, 50, 30, 10); + + @BeforeEach + void setUp() throws NoSuchFieldException, IllegalAccessException { + mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); + testUser.setId(testUserId); + when(jwtTokenProvider.validateAccessToken(any(String.class))).thenReturn(true); + when(jwtTokenProvider.getUserPk(any(String.class))).thenReturn(testUserId); + + Field foodId = Food.class.getDeclaredField("id"); + foodId.setAccessible(true); + foodId.set(testFood, testFoodId); + + Field favoriteId = FavoriteFood.class.getDeclaredField("id"); + favoriteId.setAccessible(true); + favoriteId.set(testFavoriteFood, testFavoriteFoodId); + + mapper.registerModule(new JavaTimeModule()); + } + + @DisplayName("음식 정보 저장") + @Test + @WithMockUser("test") + void testSaveFood() throws Exception { + //Given + CreateFoodDto createFoodDto = CreateFoodDto.of(testUserId, "test", testBaseNutrition, 2021, 10, 10); + + when(foodService.saveFood(any(CreateFoodDto.class))).thenReturn(testFoodId); + ApiResponse expectedResponse = ApiResponse.success(foodService.saveFood(createFoodDto), ResponseCode.FOOD_CREATE_SUCCESS.getMessage()); + + + String json = mapper.writeValueAsString(createFoodDto); + + //When & Then + mockMvc.perform(MockMvcRequestBuilders + .post("/api/food/save") + .contentType(MediaType.APPLICATION_JSON) + .content(json) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.code").value(expectedResponse.getHeader().getCode())) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.message").value(expectedResponse.getHeader().getMessage())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data").value(expectedResponse.getData())) + .andExpect(MockMvcResultMatchers.jsonPath("$.msg").value(expectedResponse.getMsg())); + } + + + @DisplayName("음식 정보 날짜별 조회") + @Test + @WithMockUser("test") + void testGetFoodListByDate() throws Exception { + //Given + int yy = 2023; + int dd = 22; + int mm = 12; + LocalDate date = LocalDate.of(yy, mm, dd); + + ResponseFoodDto food1 = ResponseFoodDto.of(testFoodId, testUserId,"test",testBaseNutrition,false,12,1); + + when(foodService.getFoodListByDate(any(Long.class), any(LocalDate.class))).thenReturn(List.of(food1)); + ApiResponse> expectedResponse = ApiResponse.success(List.of(food1), ResponseCode.FOOD_READ_SUCCESS.getMessage()); + + //When & Then + mockMvc.perform(MockMvcRequestBuilders + .get("/api/food") + .header("accessToken", "test") + .param("yy", String.valueOf(date.getYear())) + .param("mm", String.valueOf(date.getMonthValue())) + .param("dd", String.valueOf(date.getDayOfMonth())) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.code").value(expectedResponse.getHeader().getCode())) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.message").value(expectedResponse.getHeader().getMessage())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data[0].name").value(expectedResponse.getData().get(0).getName())) + //.andExpect(MockMvcResultMatchers.jsonPath("$.data[0].date").value(expectedResponse.getData().get(0).getDate().toString())) + //.andExpect(MockMvcResultMatchers.jsonPath("$.data[0].time").value(expectedResponse.getData().get(0).getTime().toString())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data[0].baseNutrition.kcal").value(expectedResponse.getData().get(0).getBaseNutrition().getKcal())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data[0].baseNutrition.carbohydrate").value(expectedResponse.getData().get(0).getBaseNutrition().getCarbohydrate())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data[0].baseNutrition.protein").value(expectedResponse.getData().get(0).getBaseNutrition().getProtein())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data[0].baseNutrition.fat").value(expectedResponse.getData().get(0).getBaseNutrition().getFat())) + .andExpect(MockMvcResultMatchers.jsonPath("$.msg").value(expectedResponse.getMsg())); + } + + @DisplayName("음식 정보 수정") + @Test + @WithMockUser("test") + void testUpdateFood() throws Exception { + //Given + int yy = 2023; + int dd = 22; + int mm = 12; + LocalDate date = LocalDate.of(yy, mm, dd); + + ApiResponse expectedResponse = ApiResponse.success(null, ResponseCode.FOOD_UPDATE_SUCCESS.getMessage()); + UpdateFoodDto updateFoodDto = UpdateFoodDto.of(testFoodId, testUserId, "testFood", testBaseNutrition); + String json = mapper.writeValueAsString(updateFoodDto); + + mockMvc.perform(MockMvcRequestBuilders + .post("/api/food/update") + .header("accessToken", "test") + .param("yy", String.valueOf(date.getYear())) + .param("mm", String.valueOf(date.getMonthValue())) + .param("dd", String.valueOf(date.getDayOfMonth())) + .contentType(MediaType.APPLICATION_JSON) + .content(json) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.code").value(expectedResponse.getHeader().getCode())) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.message").value(expectedResponse.getHeader().getMessage())) + .andExpect(MockMvcResultMatchers.jsonPath("$.msg").value(expectedResponse.getMsg())); + + } + + @DisplayName("음식 정보 삭제") + @Test + @WithMockUser("test") + void testDeleteFood() throws Exception { + //Given + int yy = 2023; + int dd = 22; + int mm = 12; + LocalDate date = LocalDate.of(yy, mm, dd); + + ApiResponse expectedResponse = ApiResponse.success(null, ResponseCode.FOOD_DELETE_SUCCESS.getMessage()); + + mockMvc.perform(MockMvcRequestBuilders + .delete("/api/food/{foodId}/delete", testFoodId) + .header("accessToken", "test") + .param("yy", String.valueOf(date.getYear())) + .param("mm", String.valueOf(date.getMonthValue())) + .param("dd", String.valueOf(date.getDayOfMonth())) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.code").value(expectedResponse.getHeader().getCode())) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.message").value(expectedResponse.getHeader().getMessage())) + .andExpect(MockMvcResultMatchers.jsonPath("$.msg").value(expectedResponse.getMsg())); + } + + @DisplayName("즐겨찾기에 음식 저장") + @Test + @WithMockUser("test") + void testSaveFavoriteFood() throws Exception { + //Given + ApiResponse expectedResponse = ApiResponse.success(testFavoriteFoodId, ResponseCode.FOOD_FAVORITE_CREATE_SUCCESS.getMessage()); + CreateFavoriteFoodDto createFavoriteFoodDto = CreateFavoriteFoodDto.of(testFoodId, testUserId, "test", testBaseNutrition); + String json = mapper.writeValueAsString(createFavoriteFoodDto); + when(foodService.saveFavoriteFood(any(CreateFavoriteFoodDto.class))).thenReturn(testFavoriteFoodId); + + mockMvc.perform(MockMvcRequestBuilders + .post("/api/food/favorite") + .contentType(MediaType.APPLICATION_JSON) + .content(json) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.code").value(expectedResponse.getHeader().getCode())) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.message").value(expectedResponse.getHeader().getMessage())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data").value(expectedResponse.getData())) + .andExpect(MockMvcResultMatchers.jsonPath("$.msg").value(expectedResponse.getMsg())); + } + + @DisplayName("즐겨찾기 음식 리스트 반환") + @Test + @WithMockUser("test") + void testGetFavoriteFoodList() throws Exception { + //Given + ResponseFavoriteFoodDto food1 = ResponseFavoriteFoodDto.of(testFavoriteFoodId,"test",testBaseNutrition,0); + + ApiResponse> expectedResponse = ApiResponse.success(List.of(food1), ResponseCode.FOOD_FAVORITE_READ_SUCCESS.getMessage()); + when(foodService.getFavoriteFoodList(any(Long.class))).thenReturn(List.of(food1)); + + //When & Then + mockMvc.perform(MockMvcRequestBuilders + .get("/api/food/favorite") + .header("accessToken", "test") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.code").value(expectedResponse.getHeader().getCode())) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.message").value(expectedResponse.getHeader().getMessage())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data[0].name").value(expectedResponse.getData().get(0).getName())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data[0].baseNutrition.kcal").value(expectedResponse.getData().get(0).getBaseNutrition().getKcal())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data[0].baseNutrition.carbohydrate").value(expectedResponse.getData().get(0).getBaseNutrition().getCarbohydrate())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data[0].baseNutrition.protein").value(expectedResponse.getData().get(0).getBaseNutrition().getProtein())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data[0].baseNutrition.fat").value(expectedResponse.getData().get(0).getBaseNutrition().getFat())) + .andExpect(MockMvcResultMatchers.jsonPath("$.msg").value(expectedResponse.getMsg())); + } + + @DisplayName("즐겨찾기 음식 수정") + @Test + @WithMockUser("test") + void testUpdateFavoriteFood() throws Exception { + //Given + ApiResponse expectedResponse = ApiResponse.success(null, ResponseCode.FOOD_FAVORITE_UPDATE_SUCCESS.getMessage()); + UpdateFavoriteFoodDto updateFavoriteFoodDto = UpdateFavoriteFoodDto.of(testFavoriteFoodId, testUserId, "testFavoriteFood", testBaseNutrition); + String json = mapper.writeValueAsString(updateFavoriteFoodDto); + + mockMvc.perform(MockMvcRequestBuilders + .post("/api/food/favorite/update") + .contentType(MediaType.APPLICATION_JSON) + .content(json) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.code").value(expectedResponse.getHeader().getCode())) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.message").value(expectedResponse.getHeader().getMessage())) + .andExpect(MockMvcResultMatchers.jsonPath("$.msg").value(expectedResponse.getMsg())); + } + + @DisplayName("즐겨찾기 음식 삭제") + @Test + @WithMockUser("test") + void testDeleteFavoriteFood() throws Exception { + //Given + ApiResponse expectedResponse = ApiResponse.success(null, ResponseCode.FOOD_FAVORITE_DELETE_SUCCESS.getMessage()); + + mockMvc.perform(MockMvcRequestBuilders + .delete("/api/food/favorite/{favoriteFoodId}", testFavoriteFoodId) + .header("accessToken", "test") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.code").value(expectedResponse.getHeader().getCode())) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.message").value(expectedResponse.getHeader().getMessage())) + .andExpect(MockMvcResultMatchers.jsonPath("$.msg").value(expectedResponse.getMsg())); + } + + //특정 날짜에 먹은 음식들의 영양성분별 총합 조회 + @DisplayName("특정 날짜에 먹은 음식들의 영양성분별 총합 조회") + @Test + @WithMockUser("test") + void testGetNutritionSumByDate() throws Exception{ + //Given + LocalDate date = LocalDate.of(2021,10,10); + ResponseNutritionSumByDateDto responseNutritionSumByDateDto = ResponseNutritionSumByDateDto.of(testUserId,date,1 + ,500,100,50,50,0.2,0.3,0.4,0.5, + BaseNutrition.createNutrition(500,100,50,50), false); + ApiResponse expectedResponse = ApiResponse.success(responseNutritionSumByDateDto,ResponseCode.FOOD_READ_SUCCESS.getMessage()); + when(foodService.getNutritionSumByDate(any(Long.class),any(LocalDate.class))).thenReturn(responseNutritionSumByDateDto); + + + //When + mockMvc.perform(MockMvcRequestBuilders + .get("/api/food/nutrition") + .header("accessToken", "test") + .param("yy", String.valueOf(date.getYear())) + .param("mm", String.valueOf(date.getMonthValue())) + .param("dd", String.valueOf(date.getDayOfMonth()))) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.code").value(expectedResponse.getHeader().getCode())) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.message").value(expectedResponse.getHeader().getMessage())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.userId").value(expectedResponse.getData().getUserId())) + //.andExpect(MockMvcResultMatchers.jsonPath("$.data.checkDate").value(expectedResponse.getData().getCheckDate().toString())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.nutritionSumType").value(expectedResponse.getData().getNutritionSumType())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.totalKcal").value(expectedResponse.getData().getTotalKcal())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.totalCarbohydrate").value(expectedResponse.getData().getTotalCarbohydrate())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.totalProtein").value(expectedResponse.getData().getTotalProtein())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.totalFat").value(expectedResponse.getData().getTotalFat())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.ratioKcal").value(expectedResponse.getData().getRatioKcal())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.ratioCarbohydrate").value(expectedResponse.getData().getRatioCarbohydrate())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.ratioProtein").value(expectedResponse.getData().getRatioProtein())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.ratioFat").value(expectedResponse.getData().getRatioFat())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.baseNutrition.carbohydrate").value(expectedResponse.getData().getBaseNutrition().getCarbohydrate())) + .andExpect(MockMvcResultMatchers.jsonPath("$.msg").value(expectedResponse.getMsg())); + } + + //최근 7일간 먹은 음식들의 영양성분별 총합 조회 + @DisplayName("최근 7일간 먹은 음식들의 영양성분별 총합 조회") + @Test + @WithMockUser("test") + void testGetNutritionSumByWeek() throws Exception { + //Given + LocalDate date = LocalDate.of(2021, 10, 10); + ResponseNutritionSumByDateDto responseNutritionSumByDateDto = ResponseNutritionSumByDateDto.of(testUserId, date, 7 + , 500, 100, 50, 50, 0.2, 0.3, 0.4, 0.5, + BaseNutrition.createNutrition(500, 100, 50, 50), false); + ApiResponse expectedResponse = ApiResponse.success(responseNutritionSumByDateDto, ResponseCode.FOOD_READ_SUCCESS.getMessage()); + when(foodService.getNutritionSumByWeek(any(Long.class), any(int.class), any(int.class), any(int.class))).thenReturn(responseNutritionSumByDateDto); + + + //When + mockMvc.perform(MockMvcRequestBuilders + .get("/api/food/nutrition/recentWeek") + .header("accessToken", "test") + .param("yy", String.valueOf(date.getYear())) + .param("mm", String.valueOf(date.getMonthValue())) + .param("dd", String.valueOf(date.getDayOfMonth()))) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.code").value(expectedResponse.getHeader().getCode())) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.message").value(expectedResponse.getHeader().getMessage())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.userId").value(expectedResponse.getData().getUserId())) + //.andExpect(MockMvcResultMatchers.jsonPath("$.data.checkDate").value(expectedResponse.getData().getCheckDate().toString())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.nutritionSumType").value(expectedResponse.getData().getNutritionSumType())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.totalKcal").value(expectedResponse.getData().getTotalKcal())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.totalCarbohydrate").value(expectedResponse.getData().getTotalCarbohydrate())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.totalProtein").value(expectedResponse.getData().getTotalProtein())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.totalFat").value(expectedResponse.getData().getTotalFat())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.ratioKcal").value(expectedResponse.getData().getRatioKcal())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.ratioCarbohydrate").value(expectedResponse.getData().getRatioCarbohydrate())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.ratioProtein").value(expectedResponse.getData().getRatioProtein())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.ratioFat").value(expectedResponse.getData().getRatioFat())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.baseNutrition.carbohydrate").value(expectedResponse.getData().getBaseNutrition().getCarbohydrate())) + .andExpect(MockMvcResultMatchers.jsonPath("$.msg").value(expectedResponse.getMsg())); + } + + //최근 한달간 먹은 음식들의 영양성분별 총합 조회 + @DisplayName("최근 한달간 먹은 음식들의 영양성분별 총합 조회") + @Test + @WithMockUser("test") + void testGetNutritionSumByMonth() throws Exception { + //Given + LocalDate date = LocalDate.of(2021, 10, 10); + ResponseNutritionSumByDateDto responseNutritionSumByDateDto = ResponseNutritionSumByDateDto.of(testUserId, date, 30 + , 500, 100, 50, 50, 0.2, 0.3, 0.4, 0.5, + BaseNutrition.createNutrition(500, 100, 50, 50), false); + ApiResponse expectedResponse = ApiResponse.success(responseNutritionSumByDateDto, ResponseCode.FOOD_READ_SUCCESS.getMessage()); + when(foodService.getNutritionSumByMonth(any(Long.class), any(int.class), any(int.class), any(int.class))).thenReturn(responseNutritionSumByDateDto); + + + //When + mockMvc.perform(MockMvcRequestBuilders + .get("/api/food/nutrition/recentMonth") + .header("accessToken", "test") + .param("yy", String.valueOf(date.getYear())) + .param("mm", String.valueOf(date.getMonthValue())) + .param("dd", String.valueOf(date.getDayOfMonth()))) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.code").value(expectedResponse.getHeader().getCode())) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.message").value(expectedResponse.getHeader().getMessage())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.userId").value(expectedResponse.getData().getUserId())) + //.andExpect(MockMvcResultMatchers.jsonPath("$.data.checkDate").value(expectedResponse.getData().getCheckDate().toString())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.nutritionSumType").value(expectedResponse.getData().getNutritionSumType())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.totalKcal").value(expectedResponse.getData().getTotalKcal())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.totalCarbohydrate").value(expectedResponse.getData().getTotalCarbohydrate())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.totalProtein").value(expectedResponse.getData().getTotalProtein())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.totalFat").value(expectedResponse.getData().getTotalFat())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.ratioKcal").value(expectedResponse.getData().getRatioKcal())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.ratioCarbohydrate").value(expectedResponse.getData().getRatioCarbohydrate())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.ratioProtein").value(expectedResponse.getData().getRatioProtein())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.ratioFat").value(expectedResponse.getData().getRatioFat())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.baseNutrition.carbohydrate").value(expectedResponse.getData().getBaseNutrition().getCarbohydrate())) + .andExpect(MockMvcResultMatchers.jsonPath("$.msg").value(expectedResponse.getMsg())); + } + + //유저의 주간 식습관 점수와 best3, worst3 음식 조회 + @DisplayName("유저의 주간 식습관 점수와 best3, worst3 음식 조회") + @Test + @WithMockUser("test") + void testGetScoreOfUserWithBestAndWorstFoods() throws Exception{ + //Given + LocalDate date = LocalDate.of(2021, 10, 10); + ResponseFoodDto food1 = ResponseFoodDto.builder().name("test").baseNutrition(BaseNutrition.createNutrition(100, 100, 100, 100)).build(); + ResponseFoodDto food2 = ResponseFoodDto.builder().name("test").baseNutrition(BaseNutrition.createNutrition(100, 100, 100, 100)).build(); + ResponseFoodDto food3 = ResponseFoodDto.builder().name("test").baseNutrition(BaseNutrition.createNutrition(100, 100, 100, 100)).build(); + ResponseFoodDto food4 = ResponseFoodDto.builder().name("test").baseNutrition(BaseNutrition.createNutrition(100, 100, 100, 100)).build(); + ResponseFoodDto food5 = ResponseFoodDto.builder().name("test").baseNutrition(BaseNutrition.createNutrition(100, 100, 100, 100)).build(); + ResponseFoodDto food6 = ResponseFoodDto.builder().name("test").baseNutrition(BaseNutrition.createNutrition(100, 100, 100, 100)).build(); + + ResponseScoreBestWorstDto responseScoreBestWorstDto = ResponseScoreBestWorstDto.of(testUserId, 100, 80 + , 60, 240, List.of(food1, food2,food3), List.of(food4, food5, food6)); + ApiResponse expectedResponse = ApiResponse.success(responseScoreBestWorstDto, ResponseCode.FOOD_READ_SUCCESS.getMessage()); + when(foodService.getScoreOfUserWithBestAndWorstFoods(any(Long.class), any(int.class), any(int.class), any(int.class))).thenReturn(responseScoreBestWorstDto); + + + //When + mockMvc.perform(MockMvcRequestBuilders + .get("/api/food/score") + .header("accessToken", "test") + .param("yy", String.valueOf(date.getYear())) + .param("mm", String.valueOf(date.getMonthValue())) + .param("dd", String.valueOf(date.getDayOfMonth()))) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.code").value(expectedResponse.getHeader().getCode())) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.message").value(expectedResponse.getHeader().getMessage())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.totalScore").value(expectedResponse.getData().getTotalScore())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.calorieScore").value(expectedResponse.getData().getCalorieScore())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.carbohydrateScore").value(expectedResponse.getData().getCarbohydrateScore())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.proteinScore").value(expectedResponse.getData().getProteinScore())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.fatScore").value(expectedResponse.getData().getFatScore())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.best[0].name").value(expectedResponse.getData().getBest().get(0).getName())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.worst[0].name").value(expectedResponse.getData().getWorst().get(0).getName())) + .andExpect(MockMvcResultMatchers.jsonPath("$.msg").value(expectedResponse.getMsg())); + } + + //유저의 일기 분석 그래프 데이터 및 식습관 totalScore 조회 + @DisplayName("유저의 일기 분석 그래프 데이터 및 식습관 totalScore 조회") + @Test + @WithMockUser("test") + void testGetAnalysisOfUser() throws Exception { + //Given + ResponseAnalysisDto responseAnalysisDto = ResponseAnalysisDto.of(100, List.of(Map.of(LocalDate.of(2021, 10, 10), 100.0)), + List.of(100.0, 100.0), List.of(Map.of(LocalDate.of(2021, 10, 10), 100.0)), List.of(100.0, 100.0), List.of(Map.of(LocalDate.of(2021, 10, 10), 100.0)), + List.of(100.0, 100.0), List.of(Map.of(LocalDate.of(2021, 10, 10), 100.0)), List.of(100.0, 100.0)); + + ApiResponse expectedResponse = ApiResponse.success(responseAnalysisDto, ResponseCode.FOOD_READ_SUCCESS.getMessage()); + when(foodService.getAnalysisOfUser(any(Long.class), any(int.class), any(int.class), any(int.class))).thenReturn(responseAnalysisDto); + + //When + mockMvc.perform(MockMvcRequestBuilders + .get("/api/food/analysis") + .header("accessToken", "test") + .param("yy", String.valueOf(2021)) + .param("mm", String.valueOf(10)) + .param("dd", String.valueOf(10))) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.code").value(expectedResponse.getHeader().getCode())) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.message").value(expectedResponse.getHeader().getMessage())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.calorieLastSevenDays[0]['2021-10-10']").value(expectedResponse.getData().getCalorieLastSevenDays().get(0).get(LocalDate.of(2021, 10, 10)))) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.carbohydrateLastSevenDays[0]['2021-10-10']").value(expectedResponse.getData().getCarbohydrateLastSevenDays().get(0).get(LocalDate.of(2021, 10, 10)))) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.proteinLastSevenDays[0]['2021-10-10']").value(expectedResponse.getData().getProteinLastSevenDays().get(0).get(LocalDate.of(2021, 10, 10)))) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.fatLastSevenDays[0]['2021-10-10']").value(expectedResponse.getData().getFatLastSevenDays().get(0).get(LocalDate.of(2021, 10, 10)))) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.calorieLastFourWeek[0]").value(expectedResponse.getData().getCalorieLastFourWeek().get(0))) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.carbohydrateLastFourWeek[0]").value(expectedResponse.getData().getCarbohydrateLastFourWeek().get(0))) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.proteinLastFourWeek[0]").value(expectedResponse.getData().getProteinLastFourWeek().get(0))) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.fatLastFourWeek[0]").value(expectedResponse.getData().getFatLastFourWeek().get(0))) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.totalScore").value(expectedResponse.getData().getTotalScore())) + .andExpect(MockMvcResultMatchers.jsonPath("$.msg").value(expectedResponse.getMsg())); + + } + + //식습관 점수 기반 주간 랭킹 조회 + @DisplayName("식습관 점수 기반 주간 랭킹 조회") + @Test + @WithMockUser("test") + void testGetUserRankByWeek() throws Exception{ + //Given + ResponseRankUserDto responseRankUserDto1 = ResponseRankUserDto.of(1L, "test", "image", 100, 100, 100, 100, 400); + ResponseRankUserDto responseRankUserDto2 = ResponseRankUserDto.of(2L, "test", "image", 100, 100, 100, 100, 400); + List responseRankUserDtoList = List.of(responseRankUserDto1, responseRankUserDto2); + + ApiResponse> expectedResponse = ApiResponse.success(responseRankUserDtoList, ResponseCode.FOOD_READ_SUCCESS.getMessage()); + when(foodService.getUserRankByWeek(any(Long.class), any(int.class), any(int.class), any(int.class))).thenReturn(responseRankUserDtoList); + + //When + mockMvc.perform(MockMvcRequestBuilders + .get("/api/food/rank") + .header("accessToken", "test") + .param("yy", String.valueOf(2021)) + .param("mm", String.valueOf(10)) + .param("dd", String.valueOf(10))) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.code").value(expectedResponse.getHeader().getCode())) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.message").value(expectedResponse.getHeader().getMessage())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data[0].userId").value(expectedResponse.getData().get(0).getUserId())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data[0].image").value(expectedResponse.getData().get(0).getImage())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data[0].totalScore").value(expectedResponse.getData().get(0).getTotalScore())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data[0].calorieScore").value(expectedResponse.getData().get(0).getCalorieScore())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data[0].carbohydrateScore").value(expectedResponse.getData().get(0).getCarbohydrateScore())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data[0].proteinScore").value(expectedResponse.getData().get(0).getProteinScore())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data[0].fatScore").value(expectedResponse.getData().get(0).getFatScore())); + } + + @DisplayName("즐겨찾기 음식으로 음식 생성") + @Test + @WithMockUser("test") + void testCreateFoodFromFavoriteFood() throws Exception{ + //Given + Long testNewFoodId = 1L; + CreateFoodFromFavoriteFoodDto createFoodFromFavoriteFoodDto = CreateFoodFromFavoriteFoodDto.of(testUserId, testFavoriteFoodId); + when(foodService.createFoodFromFavoriteFood(any(CreateFoodFromFavoriteFoodDto.class))).thenReturn(testNewFoodId); + ApiResponse expectedResponse = ApiResponse.success(foodService.createFoodFromFavoriteFood(createFoodFromFavoriteFoodDto), ResponseCode.FOOD_CREATE_SUCCESS.getMessage()); + String json = mapper.writeValueAsString(createFoodFromFavoriteFoodDto); + + + //When + mockMvc.perform(MockMvcRequestBuilders + .post("/api/food/favorite/createfrom") + .contentType(MediaType.APPLICATION_JSON) + .content(json) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.code").value(expectedResponse.getHeader().getCode())) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.message").value(expectedResponse.getHeader().getMessage())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data").value(expectedResponse.getData())) + .andExpect(MockMvcResultMatchers.jsonPath("$.msg").value(expectedResponse.getMsg())); + } +} diff --git a/src/test/java/com/diareat/diareat/controller/UserControllerTest.java b/src/test/java/com/diareat/diareat/controller/UserControllerTest.java new file mode 100644 index 0000000..cb26ebc --- /dev/null +++ b/src/test/java/com/diareat/diareat/controller/UserControllerTest.java @@ -0,0 +1,345 @@ +package com.diareat.diareat.controller; + +import com.diareat.diareat.auth.component.JwtTokenProvider; +import com.diareat.diareat.config.TestConfig; +import com.diareat.diareat.user.controller.UserController; +import com.diareat.diareat.user.domain.BaseNutrition; +import com.diareat.diareat.user.domain.User; +import com.diareat.diareat.user.dto.request.SearchUserDto; +import com.diareat.diareat.user.dto.request.UpdateUserDto; +import com.diareat.diareat.user.dto.request.UpdateUserNutritionDto; +import com.diareat.diareat.user.dto.response.ResponseSearchUserDto; +import com.diareat.diareat.user.dto.response.ResponseSimpleUserDto; +import com.diareat.diareat.user.dto.response.ResponseUserDto; +import com.diareat.diareat.user.dto.response.ResponseUserNutritionDto; +import com.diareat.diareat.user.service.UserService; +import com.diareat.diareat.util.MessageUtil; +import com.diareat.diareat.util.api.ApiResponse; +import com.diareat.diareat.util.api.ResponseCode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + + +import java.util.List; + +import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; + +@WebMvcTest(controllers = UserController.class) +@Import(TestConfig.class) +class UserControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private WebApplicationContext webApplicationContext; + + @MockBean + private JwtTokenProvider jwtTokenProvider; + + @MockBean + private UserService userService; + + private final Long testUserId = 1L; + private final ObjectMapper mapper = new ObjectMapper(); + private final User testUser = User.createUser("test", "test", "test", 180, 70, 0, 20, BaseNutrition.createNutrition(2000, 300, 80, 80)); + private final Authentication authentication = new TestingAuthenticationToken(1L, null, "ROLE_USER"); + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); + testUser.setId(testUserId); + when(jwtTokenProvider.validateAccessToken(any(String.class))).thenReturn(true); + when(jwtTokenProvider.getUserPk(any(String.class))).thenReturn(testUserId); + when(userService.getSimpleUserInfo(any(Long.class))) + .thenReturn(ResponseSimpleUserDto.builder() + .name(testUser.getName()) + .image(testUser.getImage()) + .build()); + when(userService.getUserInfo(any(Long.class))) + .thenReturn(ResponseUserDto.builder() + .name(testUser.getName()) + .height(testUser.getHeight()) + .weight(testUser.getWeight()) + .age(testUser.getAge()) + .build()); + } + + @DisplayName("회원 기본정보 조회") + @Test + @WithMockUser("test") + void testGetSimpleUserInfo() throws Exception { + // Given + ApiResponse expectedResponse = ApiResponse.success( + ResponseSimpleUserDto.builder() + .name(testUser.getName()) + .image(testUser.getImage()) + .build() + , ResponseCode.USER_CREATE_SUCCESS.getMessage()); + + // When & Then + mockMvc.perform(MockMvcRequestBuilders + .get("/api/user/info/simple") + .header("accessToken", "test") + .accept(MediaType.APPLICATION_JSON) + .with(authentication(authentication))) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.code").value(expectedResponse.getHeader().getCode())) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.message").value(expectedResponse.getHeader().getMessage())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.name").value(expectedResponse.getData().getName())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.image").value(expectedResponse.getData().getImage())); + } + + @DisplayName("회원정보 조회") + @Test + @WithMockUser("test") + void getUserInfo() throws Exception { + // Given + ApiResponse expectedResponse = ApiResponse.success( + ResponseUserDto.builder() + .name(testUser.getName()) + .height(testUser.getHeight()) + .weight(testUser.getWeight()) + .age(testUser.getAge()) + .build() + , ResponseCode.USER_CREATE_SUCCESS.getMessage()); + + // When & Then + mockMvc.perform(MockMvcRequestBuilders + .get("/api/user/info") + .header("accessToken", "test") + .accept(MediaType.APPLICATION_JSON) + .with(authentication(authentication))) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.code").value(expectedResponse.getHeader().getCode())) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.message").value(expectedResponse.getHeader().getMessage())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.name").value(expectedResponse.getData().getName())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.height").value(expectedResponse.getData().getHeight())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.weight").value(expectedResponse.getData().getWeight())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.age").value(expectedResponse.getData().getAge())); + } + + @DisplayName("회원정보 수정") + @Test + @WithMockUser("test") + void updateUser() throws Exception { + // Given + ApiResponse expectedResponse = ApiResponse.success(null, ResponseCode.USER_UPDATE_SUCCESS.getMessage()); + UpdateUserDto user = UpdateUserDto.of(testUserId, "test2", 170, 80, 21, 1); + String json = mapper.writeValueAsString(user); + + // When & Then + mockMvc.perform(MockMvcRequestBuilders + .put("/api/user/update") + .contentType(MediaType.APPLICATION_JSON) + .content(json) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.code").value(expectedResponse.getHeader().getCode())) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.message").value(expectedResponse.getHeader().getMessage())) + .andExpect(MockMvcResultMatchers.jsonPath("$.msg").value(expectedResponse.getMsg())); + } + + @DisplayName("회원정보 수정 - 유효성 검사 실패") + @Test + @WithMockUser("test") + void updateUserFail() throws Exception { + // Given + UpdateUserDto user = UpdateUserDto.of(testUserId, "", 300, 80, 500, 1); + String json = mapper.writeValueAsString(user); + + // When & Then + mockMvc.perform(MockMvcRequestBuilders + .put("/api/user/update") + .contentType(MediaType.APPLICATION_JSON) + .content(json) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().isBadRequest()) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.code").value(HttpStatus.BAD_REQUEST.value())) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.message").value(ResponseCode.BAD_REQUEST.getMessage())) + .andExpect(MockMvcResultMatchers.jsonPath("$.msg").value(ResponseCode.BAD_REQUEST.getMessage())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.name").value(MessageUtil.NOT_BLANK)) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.height").value(MessageUtil.HEIGHT_RANGE)) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.age").value(MessageUtil.AGE_RANGE)); + } + + @DisplayName("회원 기준섭취량 조회") + @Test + @WithMockUser("test") + void getUserNutrition() throws Exception { + // Given + when(userService.getUserNutrition(any(Long.class))).thenReturn( + ResponseUserNutritionDto.builder() + .calorie(2000) + .protein(300) + .fat(300) + .carbohydrate(80) + .build() + ); + ApiResponse expectedResponse = ApiResponse.success( + ResponseUserNutritionDto.builder() + .calorie(2000) + .protein(300) + .fat(300) + .carbohydrate(80) + .build(), + ResponseCode.USER_READ_SUCCESS.getMessage()); + + // When & Then + mockMvc.perform(MockMvcRequestBuilders + .get("/api/user/info/nutrition") + .header("accessToken", "test") + .accept(MediaType.APPLICATION_JSON) + .with(authentication(authentication))) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.code").value(expectedResponse.getHeader().getCode())) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.message").value(expectedResponse.getHeader().getMessage())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.calorie").value(expectedResponse.getData().getCalorie())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.protein").value(expectedResponse.getData().getProtein())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.fat").value(expectedResponse.getData().getFat())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.carbohydrate").value(expectedResponse.getData().getCarbohydrate())); + } + + @DisplayName("회원 기준섭취량 직접 수정") + @Test + @WithMockUser("test") + void updateUserNutrition() throws Exception { + // Given + ApiResponse expectedResponse = ApiResponse.success(null, ResponseCode.USER_UPDATE_SUCCESS.getMessage()); + UpdateUserNutritionDto nutrition = UpdateUserNutritionDto.of(testUserId, 2000, 300, 300, 80); + String json = mapper.writeValueAsString(nutrition); + + // When & Then + mockMvc.perform(MockMvcRequestBuilders + .put("/api/user/info/nutrition") + .contentType(MediaType.APPLICATION_JSON) + .content(json) + .accept(MediaType.APPLICATION_JSON) + .with(authentication(authentication))) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.code").value(expectedResponse.getHeader().getCode())) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.message").value(expectedResponse.getHeader().getMessage())) + .andExpect(MockMvcResultMatchers.jsonPath("$.msg").value(expectedResponse.getMsg())); + } + + @DisplayName("회원 기준섭취량 직접 수정 - 유효성 검사 실패") + @Test + @WithMockUser("test") + void updateUserNutritionFail() throws Exception { + // Given + UpdateUserNutritionDto nutrition = UpdateUserNutritionDto.of(testUserId, 12000, -1, 5000, -500); + String json = mapper.writeValueAsString(nutrition); + + // When & Then + mockMvc.perform(MockMvcRequestBuilders + .put("/api/user/info/nutrition") + .contentType(MediaType.APPLICATION_JSON) + .content(json) + .accept(MediaType.APPLICATION_JSON) + .with(authentication(authentication))) + .andExpect(MockMvcResultMatchers.status().isBadRequest()) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.code").value(HttpStatus.BAD_REQUEST.value())) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.message").value(ResponseCode.BAD_REQUEST.getMessage())) + .andExpect(MockMvcResultMatchers.jsonPath("$.msg").value(ResponseCode.BAD_REQUEST.getMessage())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.calorie").value(MessageUtil.CALORIE_RANGE)) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.protein").value(MessageUtil.PROTEIN_RANGE)) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.fat").value(MessageUtil.FAT_RANGE)) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.carbohydrate").value(MessageUtil.CARBOHYDRATE_RANGE)); + } + + @DisplayName("회원의 친구 검색 결과 조회") + @Test + @WithMockUser("test") + void searchUser() throws Exception { + // Given + when(userService.searchUser(testUserId, "test")) + .thenReturn(List.of(ResponseSearchUserDto.builder() + .userId(2L) + .name("test2") + .image("test2") + .follow(true) + .build())); + ApiResponse> expectedResponse = ApiResponse.success( + List.of(ResponseSearchUserDto.builder() + .userId(2L) + .name("test2") + .image("test2") + .follow(true) + .build() + ), + ResponseCode.USER_SEARCH_SUCCESS.getMessage()); + SearchUserDto searchDto = SearchUserDto.of(testUserId, "test"); + String json = mapper.writeValueAsString(searchDto); + + // When & Then + mockMvc.perform(MockMvcRequestBuilders + .post("/api/user/search") + .contentType(MediaType.APPLICATION_JSON) + .content(json) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.code").value(expectedResponse.getHeader().getCode())) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.message").value(expectedResponse.getHeader().getMessage())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data[0].userId").value(2L)) + .andExpect(MockMvcResultMatchers.jsonPath("$.data[0].name").value(expectedResponse.getData().get(0).getName())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data[0].image").value(expectedResponse.getData().get(0).getImage())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data[0].follow").value(expectedResponse.getData().get(0).isFollow())); + } + + @DisplayName("회원의 친구 검색 결과 조회 - 유효성 검사 실패") + @Test + @WithMockUser("test") + void searchUserFail() throws Exception { + // Given + SearchUserDto searchDto = SearchUserDto.of(testUserId, ""); + String json = mapper.writeValueAsString(searchDto); + + // When & Then + mockMvc.perform(MockMvcRequestBuilders + .post("/api/user/search") + .header("accessToken", "test") + .contentType(MediaType.APPLICATION_JSON) + .content(json) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().isBadRequest()) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.code").value(HttpStatus.BAD_REQUEST.value())) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.message").value(ResponseCode.BAD_REQUEST.getMessage())) + .andExpect(MockMvcResultMatchers.jsonPath("$.msg").value(ResponseCode.BAD_REQUEST.getMessage())) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.inputName").value(MessageUtil.NOT_BLANK)); + } + + @DisplayName("회원이 특정 회원 팔로우 및 팔로우 취소") + @Test + @WithMockUser("test") + void followUser() throws Exception { + // Given + ApiResponse expectedResponse = ApiResponse.success(null, ResponseCode.USER_FOLLOW_SUCCESS.getMessage()); + + // When & Then + mockMvc.perform(MockMvcRequestBuilders + .post("/api/user/follow/2") + .header("accessToken", "test") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.code").value(expectedResponse.getHeader().getCode())) + .andExpect(MockMvcResultMatchers.jsonPath("$.header.message").value(expectedResponse.getHeader().getMessage())) + .andExpect(MockMvcResultMatchers.jsonPath("$.msg").value(expectedResponse.getMsg())); + } +} diff --git a/src/test/java/com/diareat/diareat/service/FoodServiceTest.java b/src/test/java/com/diareat/diareat/service/FoodServiceTest.java new file mode 100644 index 0000000..1afd799 --- /dev/null +++ b/src/test/java/com/diareat/diareat/service/FoodServiceTest.java @@ -0,0 +1,543 @@ +package com.diareat.diareat.service; + +import com.diareat.diareat.food.domain.FavoriteFood; +import com.diareat.diareat.food.domain.Food; +import com.diareat.diareat.food.dto.*; +import com.diareat.diareat.food.repository.FavoriteFoodRepository; +import com.diareat.diareat.food.repository.FoodRepository; +import com.diareat.diareat.food.service.FoodService; +import com.diareat.diareat.user.domain.BaseNutrition; +import com.diareat.diareat.user.domain.User; +import com.diareat.diareat.user.dto.response.ResponseRankUserDto; +import com.diareat.diareat.user.repository.FollowRepository; +import com.diareat.diareat.user.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Sort; + + +import java.lang.reflect.Field; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + + +@ExtendWith(MockitoExtension.class) +class FoodServiceTest { + + @InjectMocks + FoodService foodService; + + @Mock + FoodRepository foodRepository; + + @Mock + FavoriteFoodRepository favoriteFoodRepository; + + @Mock + UserRepository userRepository; + + @Mock + FollowRepository followRepository; + + @DisplayName("음식 정보 저장") + @Test + void testSaveAndGetFood() throws NoSuchFieldException, IllegalAccessException { // 음식 정보 저장 및 해당 날짜 음식 리스트 불러오기 + // given + BaseNutrition testBaseNutrition = BaseNutrition.createNutrition(1,1,1,1); + User user = User.createUser("testUser", "testImage","testPassword", 1,180, 80,18, testBaseNutrition); + user.setId(1L); + + CreateFoodDto createFoodDto = CreateFoodDto.of(user.getId(), "testFood", testBaseNutrition, 2010,1,1); + Food food = Food.createFood("testFood", user, testBaseNutrition, 2010,1,1); + + Field f_id = Food.class.getDeclaredField("id"); + f_id.setAccessible(true); + f_id.set(food, 2L); + + //addedTime에 접근할 수 없으므로 Reflection 통한 값 설정 + Field addedTime = Food.class.getDeclaredField("addedTime"); + addedTime.setAccessible(true); + addedTime.set(food, LocalDate.of(2010,1,1).atTime(0,0)); + + given(userRepository.findById(user.getId())).willReturn(Optional.of(user)); + given(userRepository.existsById(user.getId())).willReturn(true); + given(foodRepository.save(any(Food.class))).willReturn(food); + given(foodRepository.findAllByUserIdAndDateOrderByAddedTimeAsc(any(Long.class), any(LocalDate.class))).willReturn(List.of(food)); + + //when + Long foodId = foodService.saveFood(createFoodDto); + + List responseFoodDtoList = foodService.getFoodListByDate(user.getId(), food.getDate()); + + assertEquals(2L, foodId); + assertEquals("testFood",responseFoodDtoList.get(0).getName()); + verify(foodRepository, times(1)).save(any(Food.class)); + } + + @Test + void testUpdateFood() throws NoSuchFieldException, IllegalAccessException { + //given + BaseNutrition testBaseNutrition = BaseNutrition.createNutrition(1,1,1,1); + User user = User.createUser("testUser", "testImage","tessPassword", 1, 180, 80, 18, testBaseNutrition); + Food food = Food.createFood("testFood", user, testBaseNutrition, 2010,1,1); + + Field foodId = Food.class.getDeclaredField("id"); + foodId.setAccessible(true); + foodId.set(food, 2L); + + given(foodRepository.findById(food.getId())).willReturn(Optional.of(food)); + + + //when + BaseNutrition testChangedBaseNutrition = BaseNutrition.createNutrition(2,3,4,5); + foodService.updateFood(UpdateFoodDto.of(food.getId(), 1L,"testChangedFood", testChangedBaseNutrition), LocalDate.of(2010, 1, 1)); + + + assertEquals("testChangedFood", food.getName()); + assertEquals(2,food.getBaseNutrition().getKcal()); + assertEquals(3,food.getBaseNutrition().getCarbohydrate()); + assertEquals(4,food.getBaseNutrition().getProtein()); + assertEquals(5,food.getBaseNutrition().getFat()); + } + // + @Test + void testDeleteFood() throws NoSuchFieldException, IllegalAccessException { + //given + BaseNutrition testBaseNutrition = BaseNutrition.createNutrition(1,1,1,1); + User user = User.createUser("testUser", "testImage","tessPassword", 1, 180, 80, 18, testBaseNutrition); + Food food = Food.createFood("testFood", user, testBaseNutrition, 2010,1,1); + + Field foodId = Food.class.getDeclaredField("id"); + foodId.setAccessible(true); + foodId.set(food, 2L); + + given(foodRepository.existsById(food.getId())).willReturn(true); + given(foodRepository.existsByIdAndUserId(food.getId(), 1L)).willReturn(true); + + //when + foodService.deleteFood(food.getId(), 1L, LocalDate.of(2010, 1, 1)); + + verify(foodRepository, times(1)).deleteById(food.getId()); + } + + @Test + void testSaveAndGetFavoriteFood() throws NoSuchFieldException, IllegalAccessException { + BaseNutrition testBaseNutrition = BaseNutrition.createNutrition(1,1,1,1); + User user = User.createUser("testUser", "testImage","testPassword", 1,180, 80,18, testBaseNutrition); + Food food = Food.createFood("testFood", user, testBaseNutrition, 2010,1,1); + FavoriteFood favoriteFood = FavoriteFood.createFavoriteFood("testFavoriteFood", user,food, testBaseNutrition); + user.setId(1L); + + Field foodId = Food.class.getDeclaredField("id"); + foodId.setAccessible(true); + foodId.set(food, 2L); + + Field fav_Id = FavoriteFood.class.getDeclaredField("id"); + fav_Id.setAccessible(true); + fav_Id.set(favoriteFood, 3L); + + CreateFavoriteFoodDto createFavoriteFoodDto = CreateFavoriteFoodDto.of(food.getId(), user.getId(),"testFood", testBaseNutrition); + + given(userRepository.findById(user.getId())).willReturn(Optional.of(user)); + given(userRepository.existsById(user.getId())).willReturn(true); + given(foodRepository.existsById(food.getId())).willReturn(true); + given(foodRepository.existsByIdAndUserId(food.getId(), user.getId())).willReturn(true); + given(foodRepository.findById(food.getId())).willReturn(Optional.of(food)); + given(favoriteFoodRepository.save(any(FavoriteFood.class))).willReturn(favoriteFood); + given(favoriteFoodRepository.findAllByUserId(any(Long.class))).willReturn(List.of(favoriteFood)); + + //when + Long favoriteFoodId = foodService.saveFavoriteFood(createFavoriteFoodDto); + + List responseFavoriteFoodDtoList = foodService.getFavoriteFoodList(user.getId()); + + assertEquals(3L, favoriteFoodId); + assertEquals("testFavoriteFood",responseFavoriteFoodDtoList.get(0).getName()); + verify(favoriteFoodRepository, times(1)).save(any(FavoriteFood.class)); + } + @Test + void testUpdateFavoriteFood() throws NoSuchFieldException, IllegalAccessException { + //given + BaseNutrition testBaseNutrition = BaseNutrition.createNutrition(1,1,1,1); + User user = User.createUser("testUser", "testImage","tessPassword", 1, 180, 80, 18, testBaseNutrition); + Food food = Food.createFood("testFood", user, testBaseNutrition, 2010,1,1); + FavoriteFood favoriteFood = FavoriteFood.createFavoriteFood("testFavoriteFood", user, food,testBaseNutrition); + + Field fav_Id = FavoriteFood.class.getDeclaredField("id"); + fav_Id.setAccessible(true); + fav_Id.set(favoriteFood, 3L); + + given(favoriteFoodRepository.findById(favoriteFood.getId())).willReturn(Optional.of(favoriteFood)); + + + //when + BaseNutrition testChangedBaseNutrition = BaseNutrition.createNutrition(2,3,4,5); + foodService.updateFavoriteFood(UpdateFavoriteFoodDto.of(favoriteFood.getId(), 1L,"testChangedFood", testChangedBaseNutrition)); + + assertEquals("testChangedFood", favoriteFood.getName()); + assertEquals(2,favoriteFood.getBaseNutrition().getKcal()); + assertEquals(3,favoriteFood.getBaseNutrition().getCarbohydrate()); + assertEquals(4,favoriteFood.getBaseNutrition().getProtein()); + assertEquals(5,favoriteFood.getBaseNutrition().getFat()); + } + // + @Test + void testDeleteFavoriteFood() throws NoSuchFieldException, IllegalAccessException { + //given + BaseNutrition testBaseNutrition = BaseNutrition.createNutrition(1,1,1,1); + User user = User.createUser("testUser", "testImage","tessPassword", 1, 180, 80, 18, testBaseNutrition); + Food food = Food.createFood("testFood", user, testBaseNutrition, 2010,1,1); + FavoriteFood favoriteFood = FavoriteFood.createFavoriteFood("testFood", user, food, testBaseNutrition); + food.setFavoriteFood(favoriteFood); + + Field fav_Id = FavoriteFood.class.getDeclaredField("id"); + fav_Id.setAccessible(true); + fav_Id.set(favoriteFood, 3L); + + given(favoriteFoodRepository.existsById(favoriteFood.getId())).willReturn(true); + given(favoriteFoodRepository.existsByIdAndUserId(favoriteFood.getId(), 1L)).willReturn(true); + + //when + foodService.deleteFavoriteFood(favoriteFood.getId(), 1L); + + verify(favoriteFoodRepository, times(1)).deleteById(favoriteFood.getId()); + } + // + @Test + void testNutritionSumByDate() throws NoSuchFieldException, IllegalAccessException { + //given + BaseNutrition testFoodNutrition = BaseNutrition.createNutrition(1400,150,200,250); + User user = User.createUser("testUser", "testImage","testPassword",1, 180, 80, 18, BaseNutrition.createNutrition(2000,300,80,80)); + user.setId(1L); + Food food = Food.createFood("testFood", user, testFoodNutrition, 2010,1,1); + + Field foodId = Food.class.getDeclaredField("id"); + foodId.setAccessible(true); + foodId.set(food, 2L); + + given(userRepository.existsById(user.getId())).willReturn(true); + given(userRepository.findById(user.getId())).willReturn(Optional.of(user)); + given(foodRepository.findAllByUserIdAndDateOrderByAddedTimeAsc(any(Long.class), any(LocalDate.class))).willReturn(List.of(food)); + + + //when + ResponseNutritionSumByDateDto responseNutritionSumByDateDto = foodService.getNutritionSumByDate(user.getId(),food.getDate()); + assertEquals(1400, responseNutritionSumByDateDto.getTotalKcal()); + assertEquals(150, responseNutritionSumByDateDto.getTotalCarbohydrate()); + assertEquals(200, responseNutritionSumByDateDto.getTotalProtein()); + assertEquals(250, responseNutritionSumByDateDto.getTotalFat()); + + assertEquals(Math.round((((double)1400 / (double)2000) * 100.0)*100.0)/100.0, responseNutritionSumByDateDto.getRatioKcal()); + assertEquals(Math.round((((double)150 / (double)300) * 100.0)*100.0)/100.0, responseNutritionSumByDateDto.getRatioCarbohydrate()); + assertEquals(Math.round((((double)200 / (double)80) * 100.0)*100.0)/100.0, responseNutritionSumByDateDto.getRatioProtein()); + assertEquals(Math.round((((double)250 / (double)80) * 100.0)*100.0)/100.0, responseNutritionSumByDateDto.getRatioFat()); + } + // + @Test + void testNutritionSumByWeek() throws NoSuchFieldException, IllegalAccessException { + //given + BaseNutrition testFoodNutrition = BaseNutrition.createNutrition(1400,150,200,250); + User user = User.createUser("testUser", "testImage","testPassword",1, 180, 80, 18, BaseNutrition.createNutrition(2000,300,80,80)); + user.setId(1L); + Food food = Food.createFood("testFood", user, testFoodNutrition, 2010,1,1); + + Field foodId = Food.class.getDeclaredField("id"); + foodId.setAccessible(true); + foodId.set(food, 2L); + + given(userRepository.existsById(user.getId())).willReturn(true); + given(userRepository.findById(user.getId())).willReturn(Optional.of(user)); + given(foodRepository.findAllByUserIdAndDateBetweenOrderByAddedTimeAsc(any(Long.class), any(LocalDate.class), any(LocalDate.class))).willReturn(List.of(food)); + + + + //when + ResponseNutritionSumByDateDto responseNutritionSumByDateDto = foodService.getNutritionSumByWeek(user.getId(), 2010,1,1); + assertEquals(1400, responseNutritionSumByDateDto.getTotalKcal()); + assertEquals(150, responseNutritionSumByDateDto.getTotalCarbohydrate()); + assertEquals(200, responseNutritionSumByDateDto.getTotalProtein()); + assertEquals(250, responseNutritionSumByDateDto.getTotalFat()); + + assertEquals(Math.round((((double)1400 / (double)2000) * 100.0)*100.0)/100.0, responseNutritionSumByDateDto.getRatioKcal()); + assertEquals(Math.round((((double)150 / (double)300) * 100.0)*100.0)/100.0, responseNutritionSumByDateDto.getRatioCarbohydrate()); + assertEquals(Math.round((((double)200 / (double)80) * 100.0)*100.0)/100.0, responseNutritionSumByDateDto.getRatioProtein()); + assertEquals(Math.round((((double)250 / (double)80) * 100.0)*100.0)/100.0, responseNutritionSumByDateDto.getRatioFat()); + + } + + @Test + void testNutritionSumByMonth() throws NoSuchFieldException, IllegalAccessException { + //given + BaseNutrition testFoodNutrition = BaseNutrition.createNutrition(1400,150,200,250); + User user = User.createUser("testUser", "testImage","testPassword",1, 180, 80, 18, BaseNutrition.createNutrition(2000,300,80,80)); + user.setId(1L); + Food food = Food.createFood("testFood", user, testFoodNutrition, 2010,1,1); + + Field foodId = Food.class.getDeclaredField("id"); + foodId.setAccessible(true); + foodId.set(food, 2L); + + given(userRepository.existsById(user.getId())).willReturn(true); + given(userRepository.findById(user.getId())).willReturn(Optional.of(user)); + given(foodRepository.findAllByUserIdAndDateBetweenOrderByAddedTimeAsc(any(Long.class), any(LocalDate.class), any(LocalDate.class))).willReturn(List.of(food)); + + + + //when + ResponseNutritionSumByDateDto responseNutritionSumByDateDto = foodService.getNutritionSumByMonth(user.getId(), 2010,1,1); + assertEquals(1400, responseNutritionSumByDateDto.getTotalKcal()); + assertEquals(150, responseNutritionSumByDateDto.getTotalCarbohydrate()); + assertEquals(200, responseNutritionSumByDateDto.getTotalProtein()); + assertEquals(250, responseNutritionSumByDateDto.getTotalFat()); + + assertEquals(Math.round((((double)1400 / (double)2000) * 100.0)*100.0)/100.0, responseNutritionSumByDateDto.getRatioKcal()); + assertEquals(Math.round((((double)150 / (double)300) * 100.0)*100.0)/100.0, responseNutritionSumByDateDto.getRatioCarbohydrate()); + assertEquals(Math.round((((double)200 / (double)80) * 100.0)*100.0)/100.0, responseNutritionSumByDateDto.getRatioProtein()); + assertEquals(Math.round((((double)250 / (double)80) * 100.0)*100.0)/100.0, responseNutritionSumByDateDto.getRatioFat()); + + } + // + @Test + void getBest3FoodTest() { + // given + User user = User.createUser("testUser", "testImage","testPassword", 1, 180, 80, 18, BaseNutrition.createNutrition(1,1,1,1)); + Food food1 = Food.createFood( "Food1", user, BaseNutrition.createNutrition(100, 100 ,10, 1), 2010,1,1); + Food food2 = Food.createFood( "Food2", user, BaseNutrition.createNutrition(100, 100 ,8, 2), 2010,1,1); + Food food3 = Food.createFood( "Food3", user, BaseNutrition.createNutrition(100, 100 ,6, 3), 2010,1,1); + Food food4 = Food.createFood( "Food4", user, BaseNutrition.createNutrition(100, 100 ,4, 4), 2010,1,1); + Food food5 = Food.createFood( "Food5", user, BaseNutrition.createNutrition(100, 100 ,2, 5), 2010,1,1); + user.setId(1L); + + List foodList = List.of(food1, food2, food3, food4, food5); + + given(foodRepository.findAllByUserIdAndDateBetweenOrderByAddedTimeAsc(any(Long.class), any(LocalDate.class), any(LocalDate.class))).willReturn(foodList); + given(userRepository.existsById(user.getId())).willReturn(true); + + // when + ResponseFoodRankDto response = foodService.getBestFoodByWeek(user.getId(), 2010,1,1); + List top3Foods = response.getRankFoodList(); + + // then + assertEquals(3, top3Foods.size()); + assertEquals("Food1", top3Foods.get(0).getName()); + assertEquals("Food2", top3Foods.get(1).getName()); + assertEquals("Food3", top3Foods.get(2).getName()); + } + // + @Test + void getWorst3FoodsTest() { + // given + User user = User.createUser("testUser", "testImage","testPassword", 1, 180, 80, 18, BaseNutrition.createNutrition(1,1,1,1)); + Food food1 = Food.createFood( "Food1", user, BaseNutrition.createNutrition(100, 50 ,10, 1), 2010,1,1); + Food food2 = Food.createFood( "Food2", user, BaseNutrition.createNutrition(100, 100 ,8, 20), 2010,1,1); + Food food3 = Food.createFood( "Food3", user, BaseNutrition.createNutrition(100, 80 ,6, 7), 2010,1,1); + Food food4 = Food.createFood( "Food4", user, BaseNutrition.createNutrition(100, 100 ,4, 5), 2010,1,1); + Food food5 = Food.createFood( "Food5", user, BaseNutrition.createNutrition(100, 90 ,2, 6), 2010,1,1); + user.setId(1L); + + List foodList = List.of(food1, food2, food3, food4, food5); + + given(foodRepository.findAllByUserIdAndDateBetweenOrderByAddedTimeAsc(any(Long.class), any(LocalDate.class), any(LocalDate.class))).willReturn(foodList); + given(userRepository.existsById(user.getId())).willReturn(true); + + // when + ResponseFoodRankDto response = foodService.getWorstFoodByWeek(user.getId(), 2010,1,1); + List top3Foods = response.getRankFoodList(); + + // then + assertEquals(3, top3Foods.size()); + assertEquals("Food2", top3Foods.get(0).getName()); + assertEquals("Food4", top3Foods.get(1).getName()); + assertEquals("Food5", top3Foods.get(2).getName()); + } + + @Test + void getUserRankByWeek() { + // given + User user1 = User.createUser("testUser1", "testImage","testPassword", 180, 80, 0, 18, BaseNutrition.createNutrition(1000,100,100,100)); + User user2 = User.createUser("testUser2", "testImage","testPassword", 180, 80, 0, 18, BaseNutrition.createNutrition(1000,100,100,100)); + user1.setId(1L); + user2.setId(2L); + + Food food1 = Food.createFood( "Food1", user1, BaseNutrition.createNutrition(1000, 100 ,100, 100), 2010,1,1); + Food food2 = Food.createFood( "Food2", user2, BaseNutrition.createNutrition(2000, 110 ,50, 90), 2010,1,1); + + given(userRepository.findById(user1.getId())).willReturn(Optional.of(user1)); + given(followRepository.findAllByFromUser(user1.getId())).willReturn(List.of(user2)); + given(foodRepository.findAllByUserIdAndDateBetweenOrderByAddedTimeAsc(eq(1L), any(LocalDate.class), any(LocalDate.class))).willReturn(List.of(food1)); + given(foodRepository.findAllByUserIdAndDateBetweenOrderByAddedTimeAsc(eq(2L), any(LocalDate.class), any(LocalDate.class))).willReturn(List.of(food2)); + + // when + List response = foodService.getUserRankByWeek(user1.getId(), 2010,1,1); + + // then + assertEquals(2, response.size()); + assertEquals("testUser1", response.get(0).getName()); + assertEquals("testUser2", response.get(1).getName()); + assertEquals(100, response.get(0).getCalorieScore()); + assertEquals(100, response.get(0).getCarbohydrateScore()); + assertEquals(100, response.get(0).getProteinScore()); + assertEquals(100, response.get(0).getFatScore()); + assertEquals(400, response.get(0).getTotalScore()); + + assertEquals(0, response.get(1).getCalorieScore()); + assertEquals(100, response.get(1).getCarbohydrateScore()); + assertEquals(50, response.get(1).getProteinScore()); + assertEquals(100, response.get(1).getFatScore()); + assertEquals(250, response.get(1).getTotalScore()); + verify(userRepository, times(1)).findById(user1.getId()); + verify(followRepository, times(1)).findAllByFromUser(user1.getId()); + verify(foodRepository, times(1)).findAllByUserIdAndDateBetweenOrderByAddedTimeAsc(eq(1L), any(LocalDate.class), any(LocalDate.class)); + verify(foodRepository, times(1)).findAllByUserIdAndDateBetweenOrderByAddedTimeAsc(eq(2L), any(LocalDate.class), any(LocalDate.class)); + } + + @Test + void testGetScoreOfUserWithBestAndWorstFoods() throws NoSuchFieldException, IllegalAccessException { + // given + User user = User.createUser("testUser", "testImage","testPassword", 1, 180, 80, 18, BaseNutrition.createNutrition(2000,400,100,50)); + Food food1 = Food.createFood( "Food1", user, BaseNutrition.createNutrition(100, 100 ,10, 1), 2010,1,1); + Food food1_1 = Food.createFood( "Food1_1", user, BaseNutrition.createNutrition(130, 100 ,8, 2), 2010,1,1); + Food food2 = Food.createFood( "Food2", user, BaseNutrition.createNutrition(150, 100 ,8, 2), 2010,1,1); + Food food3 = Food.createFood( "Food3", user, BaseNutrition.createNutrition(200, 100 ,6, 3), 2010,1,1); + user.setId(1L); + + LocalDate fixedDate = LocalDate.of(2010, 1, 1); + + Field date = Food.class.getDeclaredField("date"); + date.setAccessible(true); + date.set(food1, fixedDate); + date.set(food1_1, fixedDate); + date.set(food2, fixedDate.minusDays(2)); + date.set(food3, fixedDate.minusDays(3)); + + List foodList = List.of(food1, food1_1, food2, food3); + + given(userRepository.existsById(user.getId())).willReturn(true); + given(userRepository.getReferenceById(any(Long.class))).willReturn(user); + given(foodRepository.findAllByUserIdAndDateBetweenOrderByAddedTimeAsc(any(Long.class), any(LocalDate.class), any(LocalDate.class))).willReturn(foodList); + + // when + ResponseScoreBestWorstDto response = foodService.getScoreOfUserWithBestAndWorstFoods(user.getId(), 2010, 1, 1); + List top3Foods = response.getBest(); + List worst3Foods = response.getWorst(); + double totalScore = response.getTotalScore(); + double calorieScore = response.getCalorieScore(); + double carbohydrateScore = response.getCarbohydrateScore(); + double proteinScore = response.getProteinScore(); + double fatScore = response.getFatScore(); + + // then + assertEquals(3, top3Foods.size()); + assertEquals(3, worst3Foods.size()); + assertEquals(192.95, totalScore); + assertEquals(32.19, calorieScore); + assertEquals(111.0, carbohydrateScore); + assertEquals(32.0, proteinScore); + assertEquals(17.76, fatScore); + } + + @Test + void testGetAnalysisOfUser() throws NoSuchFieldException, IllegalAccessException { + // given + User user = User.createUser("testUser", "testImage","testPassword", 1, 180, 80, 18, BaseNutrition.createNutrition(2000,400,100,50)); + Food food1 = Food.createFood( "Food1", user, BaseNutrition.createNutrition(100, 100 ,10, 1), 2010,1,1); + Food food1_1 = Food.createFood( "Food1_1", user, BaseNutrition.createNutrition(130, 100 ,8, 2), 2010,1,1); + Food food2 = Food.createFood( "Food2", user, BaseNutrition.createNutrition(150, 100 ,8, 2), 2010,1,1); + Food food3 = Food.createFood( "Food3", user, BaseNutrition.createNutrition(200, 100 ,6, 3), 2010,1,1); + Food food4 = Food.createFood( "Food4", user, BaseNutrition.createNutrition(250, 100 ,4, 4), 2010,1,1); + Food food5 = Food.createFood( "Food5", user, BaseNutrition.createNutrition(300, 100 ,2, 5), 2010,1,1); + user.setId(1L); + + LocalDate fixedDate = LocalDate.of(2010, 5, 1); + + Field date = Food.class.getDeclaredField("date"); + date.setAccessible(true); + date.set(food1, fixedDate); + date.set(food1_1, fixedDate); + date.set(food2, fixedDate.minusDays(2)); + date.set(food3, fixedDate.minusDays(3)); + date.set(food4, fixedDate.minusWeeks(1)); + date.set(food5, fixedDate.minusWeeks(2)); + + List foodListOfWeek = List.of(food1,food1_1, food2, food3); + List foodListOfMonth = List.of(food1, food1_1,food2, food3, food4, food5); + Sort sort = Sort.by(Sort.Direction.DESC, "added_time"); + + given(userRepository.existsById(user.getId())).willReturn(true); + given(userRepository.getReferenceById(any(Long.class))).willReturn(user); + given(foodRepository.findAllByUserIdAndDateBetweenOrderByAddedTimeAsc(user.getId(), fixedDate.minusWeeks(1).plusDays(1), fixedDate.plusDays(1))).willReturn(foodListOfWeek); + given(foodRepository.findAllByUserIdAndDateBetweenOrderByAddedTimeAsc(user.getId(), fixedDate.minusWeeks(3).with(DayOfWeek.MONDAY), fixedDate.plusDays(1))).willReturn(foodListOfMonth); + given(foodRepository.findAllByUserIdAndDateBetweenOrderByAddedTimeAsc(user.getId(), fixedDate.with(DayOfWeek.MONDAY), fixedDate)).willReturn(foodListOfWeek); + + + // when + ResponseAnalysisDto response = foodService.getAnalysisOfUser(user.getId(), fixedDate.getYear(), fixedDate.getMonthValue(), fixedDate.getDayOfMonth()); + + double totalScore = response.getTotalScore(); + List> calorieLastSevenDays = response.getCalorieLastSevenDays(); + List calorieLastFourWeeks = response.getCalorieLastFourWeek(); + + + // then + assertEquals(192.95, totalScore); + + //갯수 확인 + assertEquals(7, calorieLastSevenDays.size()); //일주일동안의 음식 -> 7개 고정 + assertEquals(4, calorieLastFourWeeks.size()); //한달동안의 음식 -> 5개 + + + verify(foodRepository, times(1)).findAllByUserIdAndDateBetweenOrderByAddedTimeAsc(user.getId(), fixedDate.minusWeeks(1).plusDays(1), fixedDate.plusDays(1)); + verify(foodRepository, times(1)).findAllByUserIdAndDateBetweenOrderByAddedTimeAsc(user.getId(), fixedDate.minusWeeks(3).with(DayOfWeek.MONDAY), fixedDate.plusDays(1)); + verify(foodRepository, times(1)).findAllByUserIdAndDateBetweenOrderByAddedTimeAsc(user.getId(), fixedDate.with(DayOfWeek.MONDAY), fixedDate); + + } + + @Test + void testCreateFoodFromFavoriteFood() throws NoSuchFieldException, IllegalAccessException { + // given + User user = User.createUser("testUser", "testImage","testPassword", 1, 180, 80, 18, BaseNutrition.createNutrition(2000,400,100,50)); + Food food = Food.createFood( "Food", user, BaseNutrition.createNutrition(100, 100 ,10, 1), 2010,1,1); + FavoriteFood favoriteFood = FavoriteFood.createFavoriteFood("FavoriteFood", user, food, BaseNutrition.createNutrition(100, 100 ,10, 1)); + Food newFood = Food.createFood("FoodFromFavorite", user, BaseNutrition.createNutrition(100, 100 ,10, 1), 2010,1,1); + user.setId(1L); + + Field f_id = Food.class.getDeclaredField("id"); + f_id.setAccessible(true); + f_id.set(food, 2L); + f_id.set(newFood, 4L); + + Field fav_id = FavoriteFood.class.getDeclaredField("id"); + fav_id.setAccessible(true); + fav_id.set(favoriteFood, 3L); + + CreateFoodFromFavoriteFoodDto createFoodFromFavoriteFoodDto = CreateFoodFromFavoriteFoodDto.of(user.getId(), favoriteFood.getId()); + + + given(favoriteFoodRepository.existsById(favoriteFood.getId())).willReturn(true); + given(favoriteFoodRepository.existsByIdAndUserId(favoriteFood.getId(),user.getId())).willReturn(true); + given(favoriteFoodRepository.findById(favoriteFood.getId())).willReturn(Optional.of(favoriteFood)); + given(foodRepository.save(any(Food.class))).willReturn(newFood); + + // when + Long foodId = foodService.createFoodFromFavoriteFood(createFoodFromFavoriteFoodDto); + + // then + assertEquals(4L, foodId); + verify(foodRepository, times(1)).save(any(Food.class)); + } +} diff --git a/src/test/java/com/diareat/diareat/service/UserServiceTest.java b/src/test/java/com/diareat/diareat/service/UserServiceTest.java new file mode 100644 index 0000000..41691bf --- /dev/null +++ b/src/test/java/com/diareat/diareat/service/UserServiceTest.java @@ -0,0 +1,283 @@ +package com.diareat.diareat.service; + +import com.diareat.diareat.user.domain.BaseNutrition; +import com.diareat.diareat.user.domain.Follow; +import com.diareat.diareat.user.domain.User; +import com.diareat.diareat.user.dto.request.CreateUserDto; +import com.diareat.diareat.user.dto.request.UpdateUserDto; +import com.diareat.diareat.user.dto.request.UpdateUserNutritionDto; +import com.diareat.diareat.user.dto.response.ResponseSearchUserDto; +import com.diareat.diareat.user.dto.response.ResponseSimpleUserDto; +import com.diareat.diareat.user.dto.response.ResponseUserDto; +import com.diareat.diareat.user.dto.response.ResponseUserNutritionDto; +import com.diareat.diareat.user.repository.FollowRepository; +import com.diareat.diareat.user.repository.UserRepository; +import com.diareat.diareat.user.service.UserService; +import com.diareat.diareat.util.exception.UserException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + @InjectMocks + private UserService userService; + + @Mock + private UserRepository userRepository; + + @Mock + private FollowRepository followRepository; + + @DisplayName("회원정보 저장") + @Test + void saveUser() { // setId() 메서드로 테스트 진행함 + // Given + CreateUserDto createUserDto = CreateUserDto.of("test", "profile.jpg", "testPassword", 0, 75, 1, 25); + BaseNutrition baseNutrition = BaseNutrition.createNutrition(2000, 300, 80, 80); + User user = User.createUser(createUserDto.getName(), createUserDto.getImage(), createUserDto.getKeyCode(), createUserDto.getHeight(), createUserDto.getWeight(), createUserDto.getGender(), createUserDto.getAge(), baseNutrition); + user.setId(1L); // 테스트 커밋 중 User에 setId() 메서드 임시적으로 삽입하여 테스트 진행함 + + given(userRepository.existsByName("test")).willReturn(false); + given(userRepository.save(any(User.class))).willReturn(user); + + // When + Long id = userService.saveUser(createUserDto); // id null로 반환됨 (Mock은 실제 DB에 객체를 생성하지 않기 때문) + + // Then + assertEquals(1L, id); + verify(userRepository, times(1)).save(any(User.class)); + } + + @DisplayName("회원정보 저장 닉네임 중복") + @Test + void saveUserDupliacedName() { + // Given + CreateUserDto createUserDto = CreateUserDto.of("test", "profile.jpg", "testPassword", 0, 75, 1, 25); + BaseNutrition baseNutrition = BaseNutrition.createNutrition(2000, 300, 80, 80); + User user = User.createUser(createUserDto.getName(), createUserDto.getImage(), createUserDto.getKeyCode(), createUserDto.getHeight(), createUserDto.getWeight(), createUserDto.getGender(), createUserDto.getAge(), baseNutrition); + user.setId(1L); // 테스트 커밋 중 User에 setId() 메서드 임시적으로 삽입하여 테스트 진행함 + + given(userRepository.existsByName("test")).willReturn(true); + + // When -> 예외처리 + assertThrows(UserException.class, () -> userService.saveUser(createUserDto)); + } + + @DisplayName("회원 기본정보 조회") + @Test + void getSimpleUserInfo() { + // Given + Long userId = 1L; + User user = User.createUser("test", "profile.jpg", "keycode123", 175, 70, 0, 30, BaseNutrition.createNutrition(2000, 300, 80, 80)); + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + + // When + ResponseSimpleUserDto result = userService.getSimpleUserInfo(userId); + + // Then + assertEquals("test", result.getName()); + assertEquals("profile.jpg", result.getImage()); + } + + @DisplayName("회원정보 조회") + @Test + void getUserInfo() { + // Given + Long userId = 1L; + User user = User.createUser("test", "profile.jpg", "keycode123", 175, 70, 0, 30, BaseNutrition.createNutrition(2000, 300, 80, 80)); + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + + // When + ResponseUserDto result = userService.getUserInfo(userId); + + // Then + assertEquals("test", result.getName()); + assertEquals(30, result.getAge()); + } + + @DisplayName("회원정보 수정") + @Test + void updateUserInfo() { + // given + UpdateUserDto updateUserDto = UpdateUserDto.of(1L, "update", 180, 75, 25, 0); + User user = User.createUser("test", "profile.jpg", "keycode123", 175, 70, 0, 30, BaseNutrition.createNutrition(2000, 300, 80, 80)); + given(userRepository.findById(updateUserDto.getUserId())).willReturn(Optional.of(user)); + + // when + userService.updateUserInfo(updateUserDto); + + + // Then + assertEquals("update", user.getName()); + assertEquals(180, user.getHeight()); + assertEquals(75, user.getWeight()); + assertEquals(25, user.getAge()); + } + + @DisplayName("회원 기준섭취량 조회") + @Test + void getUserNutrition() { // 임시 코드 사용, 추후 로직 개편 시 테스트코드 수정 + // Given + Long userId = 1L; + User user = User.createUser("test", "profile.jpg", "keycode123", 175, 70, 0, 30, BaseNutrition.createNutrition(2000, 300, 80, 80)); + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + + // When + ResponseUserNutritionDto result = userService.getUserNutrition(userId); + + // Then + assertEquals(2000, result.getCalorie()); + assertEquals(300, result.getCarbohydrate()); + assertEquals(80, result.getProtein()); + assertEquals(80, result.getFat()); + } + + @DisplayName("회원 기준섭취량 직접 수정") + @Test + void updateBaseNutrition() { + // Given + UpdateUserNutritionDto updateUserNutritionDto = UpdateUserNutritionDto.of(1L, 2000, 300, 80, 80); + // 필드 초기화 + User user = User.createUser("test", "profile.jpg", "keycode123", 175, 70, 0, 30, BaseNutrition.createNutrition(1000, 100, 40, 40)); + given(userRepository.findById(updateUserNutritionDto.getUserId())).willReturn(Optional.of(user)); + + // When + userService.updateBaseNutrition(updateUserNutritionDto); + + // Then + assertEquals(2000, user.getBaseNutrition().getKcal()); + assertEquals(300, user.getBaseNutrition().getCarbohydrate()); + assertEquals(80, user.getBaseNutrition().getProtein()); + assertEquals(80, user.getBaseNutrition().getFat()); + } + + @DisplayName("회원 탈퇴") + @Test + void deleteUser() { + // Given + Long userId = 1L; + given(userRepository.existsById(userId)).willReturn(true); + + // When + userService.deleteUser(userId); + + // Then + verify(userRepository, times(1)).deleteById(userId); + } + + @DisplayName("회원의 친구 검색 결과 조회") + @Test + void searchUser() { // setId() 메서드로 테스트 진행함 + // Given + Long userId1 = 1L; + Long userId2 = 2L; + Long userId3 = 3L; + String name = "John"; + + // 사용자 목록 생성 + User user1 = User.createUser("John", "profile1.jpg", "keycode123", 175, 70, 0, 30, BaseNutrition.createNutrition(2000, 300, 80, 80)); + User user2 = User.createUser("John Doe", "profile2.jpg", "keycode456", 170, 65, 1, 35, BaseNutrition.createNutrition(2000, 300, 80, 80)); + User user3 = User.createUser("John Doo", "profile3.jpg", "keycode789", 160, 55, 1, 25, BaseNutrition.createNutrition(2000, 300, 80, 80)); + user1.setId(userId1); + user2.setId(userId2); + user3.setId(userId3); + + given(userRepository.existsById(userId1)).willReturn(true); + given(userRepository.findAllByNameContaining(name)).willReturn(List.of(user1, user2, user3)); + given(followRepository.existsByFromUserAndToUser(userId1, userId2)).willReturn(true); + given(followRepository.existsByFromUserAndToUser(userId1, userId3)).willReturn(false); + + // When + List result = userService.searchUser(userId1, name); + + // Then + assertEquals(2, result.size()); + assertEquals("John Doe", result.get(0).getName()); + assertTrue(result.get(0).isFollow()); + assertEquals("John Doo", result.get(1).getName()); + assertFalse(result.get(1).isFollow()); + verify(userRepository, times(1)).findAllByNameContaining(name); + } + + @DisplayName("회원이 특정 회원 팔로우") + @Test + void followUser() { + // Given + Long userId = 1L; // 팔로우 요청을 보낸 사용자의 ID + Long followId = 2L; // 팔로우할 사용자의 ID + + given(userRepository.existsById(userId)).willReturn(true); // userId에 해당하는 사용자가 존재함 + given(userRepository.existsById(followId)).willReturn(true); // followId에 해당하는 사용자가 존재함 + given(followRepository.existsByFromUserAndToUser(userId, followId)).willReturn(false); // 아직 팔로우 중이 아님 + + // When + userService.followUser(userId, followId); + + // Then + verify(userRepository, times(1)).existsById(userId); // 사용자 존재 여부 확인 + verify(userRepository, times(1)).existsById(followId); // 팔로우할 사용자 존재 여부 확인 + verify(followRepository, times(1)).existsByFromUserAndToUser(userId, followId); // 이미 팔로우 중인지 확인 + verify(followRepository, times(1)).save(any(Follow.class)); + } + + @DisplayName("회원이 특정 회원 팔로우 중복 요청") + @Test + void followerUserDuplicate() { + // Given + Long userId = 1L; // 팔로우 요청을 보낸 사용자의 ID + Long followId = 2L; // 팔로우할 사용자의 ID + + given(userRepository.existsById(userId)).willReturn(true); // userId에 해당하는 사용자가 존재함 + given(userRepository.existsById(followId)).willReturn(true); // followId에 해당하는 사용자가 존재함 + given(followRepository.existsByFromUserAndToUser(userId, followId)).willReturn(true); // 아직 팔로우 중이 아님 + + // When -> 예외처리 + assertThrows(UserException.class, () -> userService.followUser(userId, followId)); + } + + @DisplayName("회원이 특정 회원 팔로우 취소") + @Test + void unfollowUser() { + // Given + Long userId = 1L; // 팔로우 취소를 요청한 사용자의 ID + Long unfollowId = 2L; // 팔로우를 취소할 사용자의 ID + + given(userRepository.existsById(userId)).willReturn(true); // userId에 해당하는 사용자가 존재함 + given(userRepository.existsById(unfollowId)).willReturn(true); // unfollowId에 해당하는 사용자가 존재함 + given(followRepository.existsByFromUserAndToUser(userId, unfollowId)).willReturn(true); + + // When + userService.unfollowUser(userId, unfollowId); + + // Then + verify(followRepository, times(1)).deleteByFromUserAndToUser(userId, unfollowId); + } + + @DisplayName("회원이 특정 회원 팔로우 취소 중복 요청") + @Test + void unfollowUserDuplicate() { + // Given + Long userId = 1L; // 팔로우 취소를 요청한 사용자의 ID + Long unfollowId = 2L; // 팔로우를 취소할 사용자의 ID + + given(userRepository.existsById(userId)).willReturn(true); // userId에 해당하는 사용자가 존재함 + given(userRepository.existsById(unfollowId)).willReturn(true); // unfollowId에 해당하는 사용자가 존재함 + given(followRepository.existsByFromUserAndToUser(userId, unfollowId)).willReturn(false); + + // When -> 예외처리 + assertThrows(UserException.class, () -> userService.unfollowUser(userId, unfollowId)); + } +}