diff --git a/.github/ISSUE_TEMPLATE/feature-request-issue-template.md b/.github/ISSUE_TEMPLATE/feature-request-issue-template.md new file mode 100644 index 0000000..13806a3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request-issue-template.md @@ -0,0 +1,32 @@ +--- +name: Feature Request Issue Template +about: Suggest an idea for this project +title: "[FEATURE] OOO๊ธฐ๋Šฅ ๊ตฌํ˜„" +labels: '' +assignees: '' + +--- + +## ๐Ÿ“ Issue +> ์ถ”๊ฐ€ํ•˜๋ ค๋Š” ๊ธฐ๋Šฅ์— ๋Œ€ํ•ด ์„ค๋ช…ํ•ด ์ฃผ์„ธ์š”. + +--- + +## ๐Ÿ“„ ์„ค๋ช… + +- ๊ธฐ๋Šฅ +- ๊ธฐ๋Šฅ +- ๊ธฐ๋Šฅ + +--- + +## โœ… ์ž‘์—…ํ•  ๋‚ด์šฉ +- [ ] ํ• ์ผ +- [ ] ํ• ์ผ +- [ ] ํ• ์ผ +- [ ] ํ• ์ผ + +--- + +## ๐Ÿ™‹๐Ÿป ์ฐธ๊ณ  ์ž๋ฃŒ +> ๊ด€๋ จ ๋ฌธ์„œ๋‚˜ ์ฐธ๊ณ  ๋งํฌ๊ฐ€ ์žˆ๋‹ค๋ฉด ์ ์–ด์ฃผ์„ธ์š”. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml deleted file mode 100644 index 401d92d..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: "โœจ Feature" -description: "์ƒˆ๋กœ์šด ๊ธฐ๋Šฅ ์ถ”๊ฐ€" -labels: ["feature"] -body: - - type: textarea - attributes: - label: ๐Ÿ“„ ์„ค๋ช… - description: ์ƒˆ๋กœ์šด ๊ธฐ๋Šฅ์— ๋Œ€ํ•œ ์„ค๋ช…์„ ์ž‘์„ฑํ•ด ์ฃผ์„ธ์š”. - placeholder: ์ž์„ธํžˆ ์ ์„์ˆ˜๋ก ์ข‹์Šต๋‹ˆ๋‹ค! - validations: - required: true - - - type: textarea - attributes: - label: โœ… ์ž‘์—…ํ•  ๋‚ด์šฉ - description: ํ•  ์ผ์„ ์ฒดํฌ๋ฐ•์Šค ํ˜•ํƒœ๋กœ ์ž‘์„ฑํ•ด์ฃผ์„ธ์š”. - placeholder: ์ตœ๋Œ€ํ•œ ์„ธ๋ถ„ํ™” ํ•ด์„œ ์ ์–ด์ฃผ์„ธ์š”! - validations: - required: true - - - type: textarea - attributes: - label: ๐Ÿ™‹๐Ÿป ์ฐธ๊ณ  ์ž๋ฃŒ - description: ์ฐธ๊ณ  ์ž๋ฃŒ๊ฐ€ ์žˆ๋‹ค๋ฉด ์ž‘์„ฑํ•ด ์ฃผ์„ธ์š”. diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..9a3f5ae --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,122 @@ +name: Deploy to EC2 + +on: + push: + branches: [ main ] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Docker Build + run: docker build -t eightyage . + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }} + aws-region: ap-northeast-2 + + - name: ECR Login + run: | + aws ecr get-login-password | \ + docker login --username AWS --password-stdin ${{ secrets.ECR_URI }} + + - name: Docker Images Check + run: docker images + + - name: ECR Push + run: | + docker tag eightyage:latest ${{ secrets.ECR_URI }} + docker push ${{ secrets.ECR_URI }} + + - name: Deploy on EC2 + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.EC2_HOST }} + username: ubuntu + key: ${{ secrets.EC2_SSH_KEY }} + envs: ECR_URI,DB_URL,DB_USER,DB_PASSWORD,JWT_SECRET_KEY,AWS_ACCESS_KEY,AWS_SECRET_KEY,REDIS_HOST + + script: | + export ECR_URI=${{ secrets.ECR_URI }} + export DB_URL=${{ secrets.DB_URL }} + export DB_USER=${{ secrets.DB_USER }} + export DB_PASSWORD=${{ secrets.DB_PASSWORD }} + export JWT_SECRET_KEY=${{ secrets.JWT_SECRET_KEY }} + export AWS_ACCESS_KEY=${{ secrets.AWS_ACCESS_KEY }} + export AWS_SECRET_KEY=${{ secrets.AWS_SECRET_KEY }} + export REDIS_HOST=${{ secrets.REDIS_HOST }} + + docker ps -q --filter ancestor=$ECR_URI | xargs -r docker stop + docker ps -aq --filter ancestor=$ECR_URI | xargs -r docker rm + + aws ecr get-login-password --region ap-northeast-2 | docker login --username AWS --password-stdin $ECR_URI + docker pull $ECR_URI + docker run -d -p 8080:8080 \ + -e DB_URL=$DB_URL \ + -e DB_USER=$DB_USER \ + -e DB_PASSWORD=$DB_PASSWORD \ + -e JWT_SECRET_KEY=$JWT_SECRET_KEY \ + -e AWS_ACCESS_KEY=$AWS_ACCESS_KEY \ + -e AWS_SECRET_KEY=$AWS_SECRET_KEY \ + -e REDIS_HOST=$REDIS_HOST \ + $ECR_URI + + - name: Health Check + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.EC2_HOST }} + username: ubuntu + key: ${{ secrets.EC2_SSH_KEY }} + script: | + for i in {1..10}; do + echo "โณ Health check attempt $i..." + if curl -f http://localhost:8080/actuator/health; then + echo "โœ… Health check succeeded!" + exit 0 + fi + sleep 5 + done + echo "โŒ Health check failed after multiple attempts" + exit 1 + + - name: Notify Slack - ๋ฐฐํฌ ์„ฑ๊ณต + if: success() + run: | + curl -X POST -H 'Content-type: application/json' \ + --data '{ + "text": "โœ… *๋ฐฐํฌ ์„ฑ๊ณต!* ๐ŸŽ‰", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*โœ… ๋ฐฐํฌ ์„ฑ๊ณตํ–ˆ์Šต๋‹ˆ๋‹ค!*\n\n*๋ธŒ๋žœ์น˜:* `${{ github.ref_name }}`\n" + } + } + ] + }' \ + ${{ secrets.SLACK_WEBHOOK_URL }} + + - name: Notify Slack - ๋ฐฐํฌ ์‹คํŒจ + if: failure() + run: | + curl -X POST -H 'Content-type: application/json' \ + --data '{ + "text": "โŒ *๋ฐฐํฌ ์‹คํŒจ!* ๐Ÿ”ฅ", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*โŒ ๋ฐฐํฌ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค!*\n\n*๋ธŒ๋žœ์น˜:* `${{ github.ref_name }}`\n" + } + } + ] + }' \ + ${{ secrets.SLACK_WEBHOOK_URL }} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..58dc29b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: Java CI with Gradle + +on: + pull_request: + branches: [ "dev", "main" ] # dev, main ๋ชจ๋‘ PR ๋Œ€์ƒ + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + + services: + mysql: + image: mysql:8.0 # MySQL ์ปจํ…Œ์ด๋„ˆ ๋„์šฐ๊ธฐ + env: + MYSQL_ROOT_PASSWORD: root # Docker ๋‚ด๋ถ€ ๋น„๋ฐ€๋ฒˆํ˜ธ ์ง€์ • + MYSQL_DATABASE: team8_test # Docker ๋‚ด๋ถ€ DB ์ด๋ฆ„ + ports: + - 3306:3306 + options: --health-cmd="mysqladmin ping --silent" --health-interval=10s --health-timeout=5s --health-retries=3 + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Wait for MySQL to be ready + run: | + for i in {1..10}; do + if mysql -hmysql -P3306 -uroot -proot -e "SELECT 1"; then + echo "MySQL is up!" + break + fi + echo "Waiting for MySQL..." + sleep 5 + done + + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + + - name: Clean Gradle Cache + run: ./gradlew clean --refresh-dependencies + + - name: Test And Build with Gradle + env: + JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }} + run: ./gradlew build -Dspring.profiles.active=ci + diff --git a/.gitignore b/.gitignore index f0fb77a..2797dbc 100644 --- a/.gitignore +++ b/.gitignore @@ -33,8 +33,9 @@ Thumbs.db .env # Spring Boot application properties/yaml -application-*.yml -application-*.properties +#application-*.yml +#application-*.properties # AWS config .aws/ + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2b62ead --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM gradle:8.6-jdk17 AS build +WORKDIR /app +COPY . . +RUN gradle clean build -x test + +FROM eclipse-temurin:17-jdk-alpine +WORKDIR /app + +COPY --from=build /app/build/libs/*.jar app.jar +EXPOSE 8080 + +ENTRYPOINT ["java", "-Dspring.profiles.active=prod", "-jar", "app.jar"] \ No newline at end of file diff --git a/build.gradle b/build.gradle index 55192ab..6d882b4 100644 --- a/build.gradle +++ b/build.gradle @@ -33,11 +33,34 @@ dependencies { 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' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + // spring security + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.security:spring-security-test' + + // jwt + implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5' + runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5' + runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5' + + // cache + implementation 'org.springframework.boot:spring-boot-starter-cache' + implementation 'com.github.ben-manes.caffeine:caffeine:3.1.8' + // spring cloud AWS S3 implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3:3.3.0' + + // env + implementation 'io.github.cdimascio:java-dotenv:5.2.2' + + // redis & redisson + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.redisson:redisson:3.23.5' + + testImplementation 'org.mockito:mockito-inline:5.2.0' + + implementation 'org.springframework.boot:spring-boot-starter-actuator' } tasks.named('test') { diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/main/java/com/example/eightyage/EightyageApplication.java b/src/main/java/com/example/eightyage/EightyageApplication.java index 56da7c3..e4cbab4 100644 --- a/src/main/java/com/example/eightyage/EightyageApplication.java +++ b/src/main/java/com/example/eightyage/EightyageApplication.java @@ -2,8 +2,14 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.web.config.EnableSpringDataWebSupport; +import org.springframework.scheduling.annotation.EnableScheduling; +import static org.springframework.data.web.config.EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO; + +@EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO) @SpringBootApplication +@EnableScheduling public class EightyageApplication { public static void main(String[] args) { diff --git a/src/main/java/com/example/eightyage/domain/auth/controller/.gitkeep b/src/main/java/com/example/eightyage/domain/auth/controller/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/example/eightyage/domain/auth/controller/AuthController.java b/src/main/java/com/example/eightyage/domain/auth/controller/AuthController.java new file mode 100644 index 0000000..99693ec --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/auth/controller/AuthController.java @@ -0,0 +1,79 @@ +package com.example.eightyage.domain.auth.controller; + +import com.example.eightyage.domain.auth.dto.request.AuthSigninRequestDto; +import com.example.eightyage.domain.auth.dto.request.AuthSignupRequestDto; +import com.example.eightyage.domain.auth.dto.response.AuthAccessTokenResponseDto; +import com.example.eightyage.domain.auth.dto.response.AuthTokensResponseDto; +import com.example.eightyage.domain.auth.service.AuthService; +import com.example.eightyage.global.annotation.RefreshToken; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.annotation.Secured; +import org.springframework.web.bind.annotation.*; + +import static com.example.eightyage.domain.user.userrole.UserRole.Authority.ADMIN; +import static com.example.eightyage.domain.user.userrole.UserRole.Authority.USER; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api") +public class AuthController { + + private final AuthService authService; + private static final int REFRESH_TOKEN_TIME = 7 * 24 * 60 * 60; // 7์ฃผ์ผ + + /* ํšŒ์›๊ฐ€์ž… */ + @PostMapping("/v1/auth/signup") + public AuthAccessTokenResponseDto signup( + @Valid @RequestBody AuthSignupRequestDto request, + HttpServletResponse httpServletResponse + ) { + AuthTokensResponseDto tokensResponseDto = authService.signup(request); + + setRefreshTokenCookie(httpServletResponse, tokensResponseDto.getRefreshToken()); + + return new AuthAccessTokenResponseDto(tokensResponseDto.getAccessToken()); + } + + /* ๋กœ๊ทธ์ธ */ + @PostMapping("/v1/auth/signin") + public AuthAccessTokenResponseDto signin( + @Valid @RequestBody AuthSigninRequestDto request, + HttpServletResponse httpServletResponse + ) { + AuthTokensResponseDto tokensResponseDto = authService.signin(request); + + setRefreshTokenCookie(httpServletResponse, tokensResponseDto.getRefreshToken()); + + return new AuthAccessTokenResponseDto(tokensResponseDto.getAccessToken()); + } + + /* ํ† ํฐ ์žฌ๋ฐœ๊ธ‰ (๋กœ๊ทธ์ธ ๊ธฐ๊ฐ„ ์—ฐ์žฅ) */ + @Secured({USER, ADMIN}) + @GetMapping("/v1/auth/refresh") + public AuthAccessTokenResponseDto refresh( + @RefreshToken String refreshToken, + HttpServletResponse httpServletResponse + ) { + AuthTokensResponseDto tokensResponseDto = authService.reissueAccessToken(refreshToken); + + setRefreshTokenCookie(httpServletResponse, tokensResponseDto.getRefreshToken()); + + return new AuthAccessTokenResponseDto(tokensResponseDto.getAccessToken()); + } + + /* http only ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•ด ์ฟ ํ‚ค์— refreshToken ์ €์žฅ */ + private void setRefreshTokenCookie(HttpServletResponse response, String refreshToken) { + Cookie cookie = new Cookie("refreshToken", refreshToken); + cookie.setMaxAge(REFRESH_TOKEN_TIME); + cookie.setSecure(true); + cookie.setHttpOnly(true); + cookie.setPath("/"); + + response.addCookie(cookie); + } + + +} diff --git a/src/main/java/com/example/eightyage/domain/auth/dto/request/.gitkeep b/src/main/java/com/example/eightyage/domain/auth/dto/request/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/example/eightyage/domain/auth/dto/request/AuthSigninRequestDto.java b/src/main/java/com/example/eightyage/domain/auth/dto/request/AuthSigninRequestDto.java new file mode 100644 index 0000000..cfde537 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/auth/dto/request/AuthSigninRequestDto.java @@ -0,0 +1,15 @@ +package com.example.eightyage.domain.auth.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Builder +public class AuthSigninRequestDto { + + private String email; + private String password; + +} diff --git a/src/main/java/com/example/eightyage/domain/auth/dto/request/AuthSignupRequestDto.java b/src/main/java/com/example/eightyage/domain/auth/dto/request/AuthSignupRequestDto.java new file mode 100644 index 0000000..1dad7d2 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/auth/dto/request/AuthSignupRequestDto.java @@ -0,0 +1,32 @@ +package com.example.eightyage.domain.auth.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import static com.example.eightyage.global.dto.ValidationMessage.*; + +@Getter +@Builder +@AllArgsConstructor +public class AuthSignupRequestDto { + + @NotBlank(message = NOT_BLANK_EMAIL) + @Email(message = PATTERN_EMAIL) + private String email; + + @NotBlank(message = NOT_BLANK_NICKNAME) + private String nickname; + + @NotBlank(message = NOT_BLANK_PASSWORD) + @Pattern(regexp = PATTERN_PASSWORD_REGEXP, + message = PATTERN_PASSWORD) + private String password; + + private String passwordCheck; + + private String userRole; +} diff --git a/src/main/java/com/example/eightyage/domain/auth/dto/response/.gitkeep b/src/main/java/com/example/eightyage/domain/auth/dto/response/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/example/eightyage/domain/auth/dto/response/AuthAccessTokenResponseDto.java b/src/main/java/com/example/eightyage/domain/auth/dto/response/AuthAccessTokenResponseDto.java new file mode 100644 index 0000000..bb1890d --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/auth/dto/response/AuthAccessTokenResponseDto.java @@ -0,0 +1,14 @@ +package com.example.eightyage.domain.auth.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +public class AuthAccessTokenResponseDto { + + private final String accessToken; + +} \ No newline at end of file diff --git a/src/main/java/com/example/eightyage/domain/auth/dto/response/AuthTokensResponseDto.java b/src/main/java/com/example/eightyage/domain/auth/dto/response/AuthTokensResponseDto.java new file mode 100644 index 0000000..6153686 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/auth/dto/response/AuthTokensResponseDto.java @@ -0,0 +1,15 @@ +package com.example.eightyage.domain.auth.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +public class AuthTokensResponseDto { + + private final String accessToken; + private final String refreshToken; + +} diff --git a/src/main/java/com/example/eightyage/domain/auth/entity/.gitkeep b/src/main/java/com/example/eightyage/domain/auth/entity/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/example/eightyage/domain/auth/entity/RefreshToken.java b/src/main/java/com/example/eightyage/domain/auth/entity/RefreshToken.java new file mode 100644 index 0000000..fb481c2 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/auth/entity/RefreshToken.java @@ -0,0 +1,38 @@ +package com.example.eightyage.domain.auth.entity; + +import com.example.eightyage.domain.auth.tokenstate.TokenState; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RefreshToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long userId; + + private String token; + + @Enumerated(EnumType.STRING) + private TokenState tokenState; + + @Builder + public RefreshToken(Long userId) { + this.userId = userId; + this.token = UUID.randomUUID().toString(); + this.tokenState = TokenState.VALID; + } + + public void updateTokenStatus(TokenState tokenStatus){ + this.tokenState = tokenStatus; + } +} diff --git a/src/main/java/com/example/eightyage/domain/auth/repository/.gitkeep b/src/main/java/com/example/eightyage/domain/auth/repository/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/example/eightyage/domain/auth/repository/RefreshTokenRepository.java b/src/main/java/com/example/eightyage/domain/auth/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..624e2b8 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/auth/repository/RefreshTokenRepository.java @@ -0,0 +1,10 @@ +package com.example.eightyage.domain.auth.repository; + +import com.example.eightyage.domain.auth.entity.RefreshToken; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface RefreshTokenRepository extends JpaRepository{ + Optional findByToken(String token); +} \ No newline at end of file diff --git a/src/main/java/com/example/eightyage/domain/auth/service/.gitkeep b/src/main/java/com/example/eightyage/domain/auth/service/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/example/eightyage/domain/auth/service/AuthService.java b/src/main/java/com/example/eightyage/domain/auth/service/AuthService.java new file mode 100644 index 0000000..edf713a --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/auth/service/AuthService.java @@ -0,0 +1,73 @@ +package com.example.eightyage.domain.auth.service; + +import com.example.eightyage.domain.auth.dto.request.AuthSigninRequestDto; +import com.example.eightyage.domain.auth.dto.request.AuthSignupRequestDto; +import com.example.eightyage.domain.auth.dto.response.AuthTokensResponseDto; +import com.example.eightyage.domain.user.entity.User; +import com.example.eightyage.domain.user.service.UserService; +import com.example.eightyage.global.exception.BadRequestException; +import com.example.eightyage.global.exception.UnauthorizedException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import static com.example.eightyage.global.exception.ErrorMessage.*; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final UserService userService; + private final TokenService tokenService; + private final PasswordEncoder passwordEncoder; + + /* ํšŒ์›๊ฐ€์ž… */ + @Transactional + public AuthTokensResponseDto signup(AuthSignupRequestDto request) { + + if (!request.getPassword().equals(request.getPasswordCheck())) { + throw new BadRequestException(PASSWORD_CONFIRMATION_MISMATCH.getMessage()); + } + + User user = userService.saveUser(request.getEmail(), request.getNickname(), request.getPassword(), request.getUserRole()); + + return getTokenResponse(user); + } + + /* ๋กœ๊ทธ์ธ */ + @Transactional + public AuthTokensResponseDto signin(AuthSigninRequestDto request) { + User user = userService.findUserByEmailOrElseThrow(request.getEmail()); + + if (user.getDeletedAt() != null) { + throw new UnauthorizedException(DEACTIVATED_USER_EMAIL.getMessage()); + } + + if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) { + throw new UnauthorizedException(INVALID_PASSWORD.getMessage()); + } + + return getTokenResponse(user); + } + + /* Access Token, Refresh Token ์žฌ๋ฐœ๊ธ‰ */ + @Transactional + public AuthTokensResponseDto reissueAccessToken(String refreshToken) { + User user = tokenService.reissueToken(refreshToken); + + return getTokenResponse(user); + } + + /* Access Token, Refresh Token ์ƒ์„ฑ ๋ฐ ์ €์žฅ */ + private AuthTokensResponseDto getTokenResponse(User user) { + + String accessToken = tokenService.createAccessToken(user); + String refreshToken = tokenService.createRefreshToken(user); + + return AuthTokensResponseDto.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } +} diff --git a/src/main/java/com/example/eightyage/domain/auth/service/TokenService.java b/src/main/java/com/example/eightyage/domain/auth/service/TokenService.java new file mode 100644 index 0000000..ee0c61e --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/auth/service/TokenService.java @@ -0,0 +1,53 @@ +package com.example.eightyage.domain.auth.service; + +import com.example.eightyage.domain.auth.entity.RefreshToken; +import com.example.eightyage.domain.auth.repository.RefreshTokenRepository; +import com.example.eightyage.domain.user.entity.User; +import com.example.eightyage.domain.user.service.UserService; +import com.example.eightyage.global.util.JwtUtil; +import com.example.eightyage.global.exception.NotFoundException; +import com.example.eightyage.global.exception.UnauthorizedException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import static com.example.eightyage.domain.auth.tokenstate.TokenState.INVALIDATED; +import static com.example.eightyage.global.exception.ErrorMessage.EXPIRED_REFRESH_TOKEN; +import static com.example.eightyage.global.exception.ErrorMessage.REFRESH_TOKEN_NOT_FOUND; + +@Service +@RequiredArgsConstructor +public class TokenService { + + private final RefreshTokenRepository refreshTokenRepository; + private final UserService userService; + private final JwtUtil jwtUtil; + + /* Access Token ์ƒ์„ฑ */ + public String createAccessToken(User user) { + return jwtUtil.createAccessToken(user.getId(), user.getEmail(), user.getNickname(), user.getUserRole()); + } + + /* Refresh Token ์ƒ์„ฑ */ + public String createRefreshToken(User user) { + RefreshToken refreshToken = refreshTokenRepository.save(new RefreshToken(user.getId())); + return refreshToken.getToken(); + } + + /* Refresh Token ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ */ + public User reissueToken(String token) { + + RefreshToken refreshToken = findByTokenOrElseThrow(token); + + if (refreshToken.getTokenState() == INVALIDATED) { + throw new UnauthorizedException(EXPIRED_REFRESH_TOKEN.getMessage()); + } + refreshToken.updateTokenStatus(INVALIDATED); + + return userService.findUserByIdOrElseThrow(refreshToken.getUserId()); + } + + private RefreshToken findByTokenOrElseThrow(String token) { + return refreshTokenRepository.findByToken(token).orElseThrow( + () -> new NotFoundException(REFRESH_TOKEN_NOT_FOUND.getMessage())); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/eightyage/domain/auth/tokenstate/TokenState.java b/src/main/java/com/example/eightyage/domain/auth/tokenstate/TokenState.java new file mode 100644 index 0000000..c2f358c --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/auth/tokenstate/TokenState.java @@ -0,0 +1,6 @@ +package com.example.eightyage.domain.auth.tokenstate; + +public enum TokenState { + VALID, + INVALIDATED +} diff --git a/src/main/java/com/example/eightyage/domain/coupon/controller/.gitkeep b/src/main/java/com/example/eightyage/domain/coupon/controller/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/example/eightyage/domain/coupon/controller/CouponController.java b/src/main/java/com/example/eightyage/domain/coupon/controller/CouponController.java new file mode 100644 index 0000000..5757dab --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/coupon/controller/CouponController.java @@ -0,0 +1,41 @@ +package com.example.eightyage.domain.coupon.controller; + +import com.example.eightyage.domain.coupon.dto.request.CouponRequestDto; +import com.example.eightyage.domain.coupon.dto.response.CouponResponseDto; +import com.example.eightyage.domain.coupon.service.CouponService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class CouponController { + + private final CouponService couponService; + + @PreAuthorize("hasRole('ADMIN')") + @PostMapping("/v1/coupons") + public ResponseEntity createCoupon(@Valid @RequestBody CouponRequestDto couponRequestDto) { + return ResponseEntity.ok(couponService.saveCoupon(couponRequestDto)); + } + + @GetMapping("/v1/coupons") + public ResponseEntity> getCoupons(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size) { + return ResponseEntity.ok(couponService.getCoupons(page, size)); + } + + @GetMapping("/v1/coupons/{couponId}") + public ResponseEntity getCoupon(@PathVariable long couponId) { + return ResponseEntity.ok(couponService.getCoupon(couponId)); + } + + @PreAuthorize("hasRole('ADMIN')") + @PatchMapping("/v1/coupons/{couponId}") + public ResponseEntity updateCoupon(@PathVariable long couponId, @Valid @RequestBody CouponRequestDto couponRequestDto) { + return ResponseEntity.ok(couponService.updateCoupon(couponId, couponRequestDto)); + } +} diff --git a/src/main/java/com/example/eightyage/domain/coupon/controller/IssuedCouponController.java b/src/main/java/com/example/eightyage/domain/coupon/controller/IssuedCouponController.java new file mode 100644 index 0000000..d37aeea --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/coupon/controller/IssuedCouponController.java @@ -0,0 +1,36 @@ +package com.example.eightyage.domain.coupon.controller; + +import com.example.eightyage.domain.coupon.dto.response.IssuedCouponResponseDto; +import com.example.eightyage.domain.coupon.service.IssuedCouponService; +import com.example.eightyage.global.dto.AuthUser; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class IssuedCouponController { + + private final IssuedCouponService issuedCouponService; + + @PostMapping("/v1/coupons/{couponId}/issues") + public ResponseEntity issueCoupon(@AuthenticationPrincipal AuthUser authUser, @PathVariable Long couponId) { + return ResponseEntity.ok(issuedCouponService.issueCoupon(authUser, couponId)); + } + + @GetMapping("/v1/coupons/my") + public ResponseEntity> getMyCoupons( + @AuthenticationPrincipal AuthUser authUser, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int size) { + return ResponseEntity.ok(issuedCouponService.getMyCoupons(authUser, page, size)); + } + + @GetMapping("/v1/coupons/{issuedCouponId}") + public ResponseEntity getCoupon(@AuthenticationPrincipal AuthUser authUser, @PathVariable Long issuedCouponId) { + return ResponseEntity.ok(issuedCouponService.getCoupon(authUser, issuedCouponId)); + } +} diff --git a/src/main/java/com/example/eightyage/domain/coupon/couponstate/CouponState.java b/src/main/java/com/example/eightyage/domain/coupon/couponstate/CouponState.java new file mode 100644 index 0000000..1106d56 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/coupon/couponstate/CouponState.java @@ -0,0 +1,6 @@ +package com.example.eightyage.domain.coupon.couponstate; + +public enum CouponState { + VALID, + INVALID +} diff --git a/src/main/java/com/example/eightyage/domain/coupon/dto/request/.gitkeep b/src/main/java/com/example/eightyage/domain/coupon/dto/request/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/example/eightyage/domain/coupon/dto/request/CouponRequestDto.java b/src/main/java/com/example/eightyage/domain/coupon/dto/request/CouponRequestDto.java new file mode 100644 index 0000000..6615147 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/coupon/dto/request/CouponRequestDto.java @@ -0,0 +1,32 @@ +package com.example.eightyage.domain.coupon.dto.request; + +import com.example.eightyage.global.dto.ValidationMessage; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class CouponRequestDto { + + @NotBlank(message = ValidationMessage.NOT_BLANK_EVENT_NAME) + private String name; + + @NotBlank(message = ValidationMessage.NOT_BLANK_EVENT_DESCRIPTION) + private String description; + + @Min(value = 1, message = ValidationMessage.INVALID_EVENT_QUANTITY) + private int quantity; + + @NotNull(message = ValidationMessage.NOT_NULL_START_DATE) + private LocalDateTime startDate; + + @NotNull(message = ValidationMessage.NOT_NULL_END_DATE) + private LocalDateTime endDate; +} diff --git a/src/main/java/com/example/eightyage/domain/coupon/dto/response/.gitkeep b/src/main/java/com/example/eightyage/domain/coupon/dto/response/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/example/eightyage/domain/coupon/dto/response/CouponResponseDto.java b/src/main/java/com/example/eightyage/domain/coupon/dto/response/CouponResponseDto.java new file mode 100644 index 0000000..3bb6bd8 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/coupon/dto/response/CouponResponseDto.java @@ -0,0 +1,27 @@ +package com.example.eightyage.domain.coupon.dto.response; + +import com.example.eightyage.domain.coupon.couponstate.CouponState; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class CouponResponseDto { + + private final String name; + private final String description; + private final int quantity; + private final LocalDateTime startDate; + private final LocalDateTime endDate; + private final CouponState state; + + + public CouponResponseDto(String name, String description, int quantity, LocalDateTime startDate, LocalDateTime endDate, CouponState state) { + this.name = name; + this.description = description; + this.quantity = quantity; + this.startDate = startDate; + this.endDate = endDate; + this.state = state; + } +} diff --git a/src/main/java/com/example/eightyage/domain/coupon/dto/response/IssuedCouponResponseDto.java b/src/main/java/com/example/eightyage/domain/coupon/dto/response/IssuedCouponResponseDto.java new file mode 100644 index 0000000..c97fb99 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/coupon/dto/response/IssuedCouponResponseDto.java @@ -0,0 +1,29 @@ +package com.example.eightyage.domain.coupon.dto.response; + +import com.example.eightyage.domain.coupon.status.Status; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class IssuedCouponResponseDto { + + private final String serialCode; + private final Status status; + private final String username; + private final String eventname; + + private final LocalDateTime startAt; + private final LocalDateTime endAt; + + public IssuedCouponResponseDto(String serialCode, Status status, + String username, String eventname, + LocalDateTime startAt, LocalDateTime endAt) { + this.serialCode = serialCode; + this.status = status; + this.username = username; + this.eventname = eventname; + this.startAt = startAt; + this.endAt = endAt; + } +} diff --git a/src/main/java/com/example/eightyage/domain/coupon/entity/.gitkeep b/src/main/java/com/example/eightyage/domain/coupon/entity/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/example/eightyage/domain/coupon/entity/Coupon.java b/src/main/java/com/example/eightyage/domain/coupon/entity/Coupon.java new file mode 100644 index 0000000..8699d08 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/coupon/entity/Coupon.java @@ -0,0 +1,66 @@ +package com.example.eightyage.domain.coupon.entity; + +import com.example.eightyage.domain.coupon.couponstate.CouponState; +import com.example.eightyage.domain.coupon.dto.request.CouponRequestDto; +import com.example.eightyage.domain.coupon.dto.response.CouponResponseDto; +import com.example.eightyage.global.entity.TimeStamped; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor +public class Coupon extends TimeStamped { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private String name; + private String description; + private int quantity; + @Column(name="start_at") + private LocalDateTime startDate; + @Column(name = "end_at") + private LocalDateTime endDate; + @Enumerated(EnumType.STRING) + private CouponState state; + + public Coupon(String name, String description, int quantity, LocalDateTime startDate, LocalDateTime endDate) { + this.name = name; + this.description = description; + this.quantity = quantity; + this.startDate = startDate; + this.endDate = endDate; + } + + public CouponResponseDto toDto() { + return new CouponResponseDto( + this.getName(), + this.getDescription(), + this.getQuantity(), + this.getStartDate(), + this.getEndDate(), + this.getState() + ); + } + + public void update(CouponRequestDto couponRequestDto) { + this.name = couponRequestDto.getName(); + this.description = couponRequestDto.getDescription(); + this.quantity = couponRequestDto.getQuantity(); + this.startDate = couponRequestDto.getStartDate(); + this.endDate = couponRequestDto.getEndDate(); + } + + public boolean isValidAt(LocalDateTime time) { + return (startDate.isBefore(time) || startDate.isEqual(time)) && (endDate.isAfter(time) || endDate.isEqual(time)); + } + + public void updateStateAt(LocalDateTime time) { + CouponState newState = isValidAt(time) ? CouponState.VALID : CouponState.INVALID; + this.state = newState; + } +} diff --git a/src/main/java/com/example/eightyage/domain/coupon/entity/IssuedCoupon.java b/src/main/java/com/example/eightyage/domain/coupon/entity/IssuedCoupon.java new file mode 100644 index 0000000..0926367 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/coupon/entity/IssuedCoupon.java @@ -0,0 +1,56 @@ +package com.example.eightyage.domain.coupon.entity; + +import com.example.eightyage.domain.coupon.dto.response.IssuedCouponResponseDto; +import com.example.eightyage.domain.coupon.status.Status; +import com.example.eightyage.domain.user.entity.User; +import com.example.eightyage.global.entity.TimeStamped; +import com.example.eightyage.global.util.RandomCodeGenerator; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class IssuedCoupon extends TimeStamped { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true) + private String serialCode; + + @Enumerated(EnumType.STRING) + private Status status; + + @ManyToOne(fetch = FetchType.LAZY) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + private Coupon coupon; + + public static IssuedCoupon create(User user, Coupon coupon) { + return IssuedCoupon.builder() + .serialCode(RandomCodeGenerator.generateCouponCode(10)) + .status(Status.VALID) + .user(user) + .coupon(coupon) + .build(); + } + + public IssuedCouponResponseDto toDto() { + return new IssuedCouponResponseDto( + this.serialCode, + this.status, + this.user.getNickname(), + this.coupon.getName(), + this.coupon.getStartDate(), + this.coupon.getEndDate() + ); + } +} diff --git a/src/main/java/com/example/eightyage/domain/coupon/repository/.gitkeep b/src/main/java/com/example/eightyage/domain/coupon/repository/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/example/eightyage/domain/coupon/repository/CouponRepository.java b/src/main/java/com/example/eightyage/domain/coupon/repository/CouponRepository.java new file mode 100644 index 0000000..d617d00 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/coupon/repository/CouponRepository.java @@ -0,0 +1,9 @@ +package com.example.eightyage.domain.coupon.repository; + +import com.example.eightyage.domain.coupon.entity.Coupon; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface CouponRepository extends JpaRepository { +} diff --git a/src/main/java/com/example/eightyage/domain/coupon/repository/IssuedCouponRepository.java b/src/main/java/com/example/eightyage/domain/coupon/repository/IssuedCouponRepository.java new file mode 100644 index 0000000..e7b97da --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/coupon/repository/IssuedCouponRepository.java @@ -0,0 +1,14 @@ +package com.example.eightyage.domain.coupon.repository; + +import com.example.eightyage.domain.coupon.entity.IssuedCoupon; +import com.example.eightyage.domain.coupon.status.Status; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface IssuedCouponRepository extends JpaRepository { + boolean existsByUserIdAndCouponId(Long userId, Long couponId); + Page findAllByUserIdAndStatus(Long userId, Status status, Pageable pageable); +} diff --git a/src/main/java/com/example/eightyage/domain/coupon/service/.gitkeep b/src/main/java/com/example/eightyage/domain/coupon/service/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/example/eightyage/domain/coupon/service/CouponService.java b/src/main/java/com/example/eightyage/domain/coupon/service/CouponService.java new file mode 100644 index 0000000..b71ddd1 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/coupon/service/CouponService.java @@ -0,0 +1,97 @@ +package com.example.eightyage.domain.coupon.service; + +import com.example.eightyage.domain.coupon.dto.request.CouponRequestDto; +import com.example.eightyage.domain.coupon.dto.response.CouponResponseDto; +import com.example.eightyage.domain.coupon.entity.Coupon; +import com.example.eightyage.domain.coupon.couponstate.CouponState; +import com.example.eightyage.domain.coupon.repository.CouponRepository; +import com.example.eightyage.global.exception.BadRequestException; +import com.example.eightyage.global.exception.ErrorMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +public class CouponService { + + private final CouponRepository couponRepository; + private final StringRedisTemplate stringRedisTemplate; + + public CouponResponseDto saveCoupon(CouponRequestDto couponRequestDto) { + Coupon coupon = new Coupon( + couponRequestDto.getName(), + couponRequestDto.getDescription(), + couponRequestDto.getQuantity(), + couponRequestDto.getStartDate(), + couponRequestDto.getEndDate() + ); + + checkCouponState(coupon); + + Coupon savedCoupon = couponRepository.save(coupon); + + stringRedisTemplate.opsForValue().set("event:quantity:" + savedCoupon.getId(), String.valueOf(savedCoupon.getQuantity())); + + return savedCoupon.toDto(); + } + + public Page getCoupons(int page, int size) { + Pageable pageable = PageRequest.of(page-1, size); + Page events = couponRepository.findAll(pageable); + + // ๋ชจ๋“  events๋“ค checkState๋กœ state ์ƒํƒœ ๊ฐฑ์‹ ํ•˜๊ธฐ + events.forEach(this::checkCouponState); + + return events.map(Coupon::toDto); + } + + public CouponResponseDto getCoupon(long couponId) { + Coupon coupon = findByIdOrElseThrow(couponId); + + checkCouponState(coupon); + + return coupon.toDto(); + } + + public CouponResponseDto updateCoupon(long couponId, CouponRequestDto couponRequestDto) { + Coupon coupon = findByIdOrElseThrow(couponId); + + coupon.update(couponRequestDto); + + checkCouponState(coupon); + + return coupon.toDto(); + } + + private void checkCouponState(Coupon coupon) { + CouponState prevState = coupon.getState(); + coupon.updateStateAt(LocalDateTime.now()); + + if(coupon.getState() != prevState) { + couponRepository.save(coupon); + } + } + + public Coupon getValidCouponOrThrow(Long couponId) { + Coupon coupon = findByIdOrElseThrow(couponId); + + coupon.updateStateAt(LocalDateTime.now()); + + if(coupon.getState() != CouponState.VALID) { + throw new BadRequestException(ErrorMessage.INVALID_EVENT_PERIOD.getMessage()); + } + + return coupon; + } + + public Coupon findByIdOrElseThrow(Long couponId) { + return couponRepository.findById(couponId) + .orElseThrow(() -> new BadRequestException(ErrorMessage.EVENT_NOT_FOUND.getMessage())); + } +} diff --git a/src/main/java/com/example/eightyage/domain/coupon/service/IssuedCouponService.java b/src/main/java/com/example/eightyage/domain/coupon/service/IssuedCouponService.java new file mode 100644 index 0000000..913d046 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/coupon/service/IssuedCouponService.java @@ -0,0 +1,102 @@ +package com.example.eightyage.domain.coupon.service; + +import com.example.eightyage.domain.coupon.dto.response.IssuedCouponResponseDto; +import com.example.eightyage.domain.coupon.entity.IssuedCoupon; +import com.example.eightyage.domain.coupon.repository.IssuedCouponRepository; +import com.example.eightyage.domain.coupon.entity.Coupon; +import com.example.eightyage.domain.coupon.status.Status; +import com.example.eightyage.domain.user.entity.User; +import com.example.eightyage.global.dto.AuthUser; +import com.example.eightyage.global.exception.BadRequestException; +import com.example.eightyage.global.exception.ErrorMessage; +import com.example.eightyage.global.exception.ForbiddenException; +import com.example.eightyage.global.exception.NotFoundException; +import lombok.RequiredArgsConstructor; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +public class IssuedCouponService { + + private final IssuedCouponRepository issuedCouponRepository; + private final CouponService couponService; + private final StringRedisTemplate stringRedisTemplate; + + private static final String EVENT_QUANTITIY_PREFIX = "event:quantity:"; + private static final String EVENT_LOCK_PREFIX = "event:lock:"; + private final RedissonClient redissonClient; + + public IssuedCouponResponseDto issueCoupon(AuthUser authUser, Long couponId) { + + RLock rLock = redissonClient.getLock(EVENT_LOCK_PREFIX + couponId); + boolean isLocked = false; + + try { + isLocked = rLock.tryLock(3, 10, TimeUnit.SECONDS); // 3์ดˆ ์•ˆ์— ๋ฝ์„ ํš๋“, 10์ดˆ ๋’ค์—๋Š” ์ž๋™ ํ•ด์ œ + + if (!isLocked) { + throw new BadRequestException(ErrorMessage.CAN_NOT_ACCESS.getMessage()); // ๋ฝ ํš๋“ ์‹คํŒจ + } + + Coupon coupon = couponService.getValidCouponOrThrow(couponId); + + if (issuedCouponRepository.existsByUserIdAndCouponId(authUser.getUserId(), couponId)) { + throw new BadRequestException(ErrorMessage.COUPON_ALREADY_ISSUED.getMessage()); + } + + Long remain = Long.parseLong(stringRedisTemplate.opsForValue().get(EVENT_QUANTITIY_PREFIX + couponId)); + if (remain == 0 || remain < 0) { + throw new BadRequestException(ErrorMessage.COUPON_OUT_OF_STOCK.getMessage()); + } + stringRedisTemplate.opsForValue().decrement(EVENT_QUANTITIY_PREFIX + couponId); + + // ์ฟ ํฐ ๋ฐœ๊ธ‰ ๋ฐ ์ €์žฅ + IssuedCoupon issuedCoupon = IssuedCoupon.create(User.fromAuthUser(authUser), coupon); + issuedCouponRepository.save(issuedCoupon); + + return issuedCoupon.toDto(); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new BadRequestException(ErrorMessage.INTERNAL_SERVER_ERROR.getMessage()); + } finally { + if (isLocked) { + rLock.unlock(); + } + } + } + + public Page getMyCoupons(AuthUser authUser, int page, int size) { + Pageable pageable = PageRequest.of(page-1, size); + Page coupons = issuedCouponRepository.findAllByUserIdAndStatus(authUser.getUserId(), Status.VALID, pageable); + + return coupons.map(IssuedCoupon::toDto); + } + + public IssuedCouponResponseDto getCoupon(AuthUser authUser, Long issuedCouponId) { + IssuedCoupon issuedCoupon = findByIdOrElseThrow(issuedCouponId); + + if(!issuedCoupon.getUser().equals(User.fromAuthUser(authUser))) { + throw new ForbiddenException(ErrorMessage.COUPON_FORBIDDEN.getMessage()); + } + + if(issuedCoupon.getStatus().equals(Status.INVALID)) { + throw new BadRequestException(ErrorMessage.COUPON_ALREADY_USED.getMessage()); + } + + return issuedCoupon.toDto(); + } + + public IssuedCoupon findByIdOrElseThrow(Long issuedCouponId) { + return issuedCouponRepository.findById(issuedCouponId) + .orElseThrow(() -> new NotFoundException(ErrorMessage.COUPON_NOT_FOUND.getMessage())); + } +} diff --git a/src/main/java/com/example/eightyage/domain/coupon/status/Status.java b/src/main/java/com/example/eightyage/domain/coupon/status/Status.java new file mode 100644 index 0000000..cfd21f3 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/coupon/status/Status.java @@ -0,0 +1,6 @@ +package com.example.eightyage.domain.coupon.status; + +public enum Status { + VALID, + INVALID, +} diff --git a/src/main/java/com/example/eightyage/domain/product/category/Category.java b/src/main/java/com/example/eightyage/domain/product/category/Category.java new file mode 100644 index 0000000..12c9345 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/product/category/Category.java @@ -0,0 +1,24 @@ +package com.example.eightyage.domain.product.category; + +public enum Category { + SKINCARE("์Šคํ‚จ์ผ€์–ด"), + MAKEUP("๋ฉ”์ดํฌ์—…"), + HAIRCARE("ํ—ค์–ด์ผ€์–ด"), + BODYCARE("๋ฐ”๋””์ผ€์–ด"), + FRAGRANCE("ํ–ฅ์ˆ˜"), + SUNCARE("์„ ์ผ€์–ด"), + CLEANSING("ํด๋ Œ์ง•"), + MASK_PACK("๋งˆ์ŠคํฌํŒฉ"), + MEN_CARE("๋‚จ์„ฑ์šฉ"), + TOOL("๋ทฐํ‹ฐ ๋„๊ตฌ"); + + private final String displayName; + + Category(String displayName) { + this.displayName = displayName; + } + + public String getDisplayName() { + return displayName; + } +} diff --git a/src/main/java/com/example/eightyage/domain/product/controller/.gitkeep b/src/main/java/com/example/eightyage/domain/product/controller/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/example/eightyage/domain/product/controller/ProductController.java b/src/main/java/com/example/eightyage/domain/product/controller/ProductController.java new file mode 100644 index 0000000..f44cbd4 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/product/controller/ProductController.java @@ -0,0 +1,96 @@ +package com.example.eightyage.domain.product.controller; + +import com.example.eightyage.domain.product.dto.request.ProductSaveRequestDto; +import com.example.eightyage.domain.product.dto.request.ProductUpdateRequestDto; +import com.example.eightyage.domain.product.dto.response.ProductGetResponseDto; +import com.example.eightyage.domain.product.dto.response.ProductSaveResponseDto; +import com.example.eightyage.domain.product.dto.response.ProductSearchResponseDto; +import com.example.eightyage.domain.product.dto.response.ProductUpdateResponseDto; +import com.example.eightyage.domain.product.category.Category; +import com.example.eightyage.domain.product.service.ProductService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.annotation.Secured; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class ProductController { + + private final ProductService productService; + + // ์ œํ’ˆ ์ƒ์„ฑ + @Secured("ROLE_ADMIN") + @PostMapping("/v1/products") + public ResponseEntity saveProduct(@Valid @RequestBody ProductSaveRequestDto requestDto){ + ProductSaveResponseDto responseDto = productService.saveProduct(requestDto); + + return new ResponseEntity<>(responseDto, HttpStatus.CREATED); + } + + // ์ œํ’ˆ ์ˆ˜์ • + @Secured("ROLE_ADMIN") + @PatchMapping("/v1/products/{productId}") + public ResponseEntity updateProduct( + @PathVariable Long productId, + @RequestBody ProductUpdateRequestDto requestDto + ){ + ProductUpdateResponseDto responseDto = productService.updateProduct(productId, requestDto); + + return ResponseEntity.ok(responseDto); + } + + // ์ œํ’ˆ ๋‹จ๊ฑด ์กฐํšŒ + @GetMapping("/v1/products/{productId}") + public ResponseEntity findProduct(@PathVariable Long productId){ + ProductGetResponseDto responseDto = productService.getProductById(productId); + + return ResponseEntity.ok(responseDto); + } + + // ์ œํ’ˆ ๋‹ค๊ฑด ์กฐํšŒ version 1 + @GetMapping("/v1/products") + public ResponseEntity> searchProductV1( + @RequestParam(required = false) String name, + @RequestParam(required = false) Category category, + @RequestParam(defaultValue = "10") int size, + @RequestParam(defaultValue = "1") int page + ) { + return ResponseEntity.ok(productService.getProductsV1(name, category, size, page)); + } + + // ์ œํ’ˆ ๋‹ค๊ฑด ์กฐํšŒ version 2 + @GetMapping("/v2/products") + public ResponseEntity> searchProductV2( + @RequestParam(required = false) String name, + @RequestParam(required = false) Category category, + @RequestParam(defaultValue = "10") int size, + @RequestParam(defaultValue = "1") int page + ) { + return ResponseEntity.ok(productService.getProductsV2(name, category, size, page)); + } + + // ์ œํ’ˆ ๋‹ค๊ฑด ์กฐํšŒ version 3 + @GetMapping("/v3/products") + public ResponseEntity> searchProductV3( + @RequestParam(required = false) String name, + @RequestParam(required = false) Category category, + @RequestParam(defaultValue = "10") int size, + @RequestParam(defaultValue = "1") int page + ) { + return ResponseEntity.ok(productService.getProductsV3(name, category, size, page)); + } + + // ์ œํ’ˆ ์‚ญ์ œ + @Secured("ROLE_ADMIN") + @DeleteMapping("/v1/products/{productId}") + public ResponseEntity deleteProduct(@PathVariable Long productId){ + productService.deleteProduct(productId); + + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } +} diff --git a/src/main/java/com/example/eightyage/domain/product/controller/ProductImageController.java b/src/main/java/com/example/eightyage/domain/product/controller/ProductImageController.java new file mode 100644 index 0000000..165a743 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/product/controller/ProductImageController.java @@ -0,0 +1,37 @@ +package com.example.eightyage.domain.product.controller; + +import com.example.eightyage.domain.product.service.ProductImageService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.annotation.Secured; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; + +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class ProductImageController { + + private final ProductImageService productImageService; + + // ์ œํ’ˆ ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ + @Secured("ROLE_ADMIN") + @PostMapping("/v1/products/{productId}/images") + public ResponseEntity uploadImage( + @PathVariable Long productId, + @RequestParam("file") MultipartFile file) { + + String imageUrl = productImageService.uploadImage(productId, file); + return ResponseEntity.ok(imageUrl); + } + + // ์ œํ’ˆ ์ด๋ฏธ์ง€ ์‚ญ์ œ + @Secured("ROLE_ADMIN") + @DeleteMapping("/v1/products/images/{imageId}") + public ResponseEntity deleteImage(@PathVariable Long imageId) { + productImageService.deleteImage(imageId); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } +} diff --git a/src/main/java/com/example/eightyage/domain/product/dto/request/.gitkeep b/src/main/java/com/example/eightyage/domain/product/dto/request/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/example/eightyage/domain/product/dto/request/ProductSaveRequestDto.java b/src/main/java/com/example/eightyage/domain/product/dto/request/ProductSaveRequestDto.java new file mode 100644 index 0000000..a7cd103 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/product/dto/request/ProductSaveRequestDto.java @@ -0,0 +1,25 @@ +package com.example.eightyage.domain.product.dto.request; + +import com.example.eightyage.domain.product.category.Category; +import com.example.eightyage.global.dto.ValidationMessage; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ProductSaveRequestDto { + + @NotBlank(message= ValidationMessage.NOT_BLANK_PRODUCT_NAME) + private String productName; + + @NotNull(message=ValidationMessage.NOT_NULL_CATEGORY) + private Category category; + + @NotBlank(message=ValidationMessage.NOT_BLANK_CONTENT) + private String content; + + @NotNull(message=ValidationMessage.NOT_NULL_PRICE) + private Integer price; +} diff --git a/src/main/java/com/example/eightyage/domain/product/dto/request/ProductUpdateRequestDto.java b/src/main/java/com/example/eightyage/domain/product/dto/request/ProductUpdateRequestDto.java new file mode 100644 index 0000000..272fccc --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/product/dto/request/ProductUpdateRequestDto.java @@ -0,0 +1,16 @@ +package com.example.eightyage.domain.product.dto.request; + +import com.example.eightyage.domain.product.category.Category; +import com.example.eightyage.domain.product.salestate.SaleState; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ProductUpdateRequestDto { + private String productName; + private Category category; + private String content; + private SaleState saleState; + private Integer price; +} diff --git a/src/main/java/com/example/eightyage/domain/product/dto/response/.gitkeep b/src/main/java/com/example/eightyage/domain/product/dto/response/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/example/eightyage/domain/product/dto/response/ProductGetResponseDto.java b/src/main/java/com/example/eightyage/domain/product/dto/response/ProductGetResponseDto.java new file mode 100644 index 0000000..60d93ab --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/product/dto/response/ProductGetResponseDto.java @@ -0,0 +1,25 @@ +package com.example.eightyage.domain.product.dto.response; + +import com.example.eightyage.domain.product.category.Category; +import com.example.eightyage.domain.product.entity.ProductImage; +import com.example.eightyage.domain.product.salestate.SaleState; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Builder +@AllArgsConstructor +public class ProductGetResponseDto { + private final String productName; + private final String content; + private final Category category; + private final Integer price; + private final SaleState saleState; + private final List productImageList; + private final LocalDateTime createdAt; + private final LocalDateTime modifiedAt; +} diff --git a/src/main/java/com/example/eightyage/domain/product/dto/response/ProductSaveResponseDto.java b/src/main/java/com/example/eightyage/domain/product/dto/response/ProductSaveResponseDto.java new file mode 100644 index 0000000..86abc5d --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/product/dto/response/ProductSaveResponseDto.java @@ -0,0 +1,22 @@ +package com.example.eightyage.domain.product.dto.response; + +import com.example.eightyage.domain.product.category.Category; +import com.example.eightyage.domain.product.salestate.SaleState; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +@AllArgsConstructor +public class ProductSaveResponseDto { + private final String productName; + private final Category category; + private final Integer price; + private final String content; + private final SaleState saleState; + private final LocalDateTime createdAt; + private final LocalDateTime modifiedAt; +} diff --git a/src/main/java/com/example/eightyage/domain/product/dto/response/ProductSearchResponseDto.java b/src/main/java/com/example/eightyage/domain/product/dto/response/ProductSearchResponseDto.java new file mode 100644 index 0000000..3205518 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/product/dto/response/ProductSearchResponseDto.java @@ -0,0 +1,26 @@ +package com.example.eightyage.domain.product.dto.response; + +import com.example.eightyage.domain.product.category.Category; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Builder +@Getter +@NoArgsConstructor +public class ProductSearchResponseDto { + private String name; + private Category category; + private Integer price; + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "0.0") + private Double scoreAvg; + + public ProductSearchResponseDto(String name, Category category, Integer price, Double scoreAvg) { + this.name = name; + this.category = category; + this.price = price; + this.scoreAvg = scoreAvg; + } +} diff --git a/src/main/java/com/example/eightyage/domain/product/dto/response/ProductUpdateResponseDto.java b/src/main/java/com/example/eightyage/domain/product/dto/response/ProductUpdateResponseDto.java new file mode 100644 index 0000000..d44aea6 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/product/dto/response/ProductUpdateResponseDto.java @@ -0,0 +1,22 @@ +package com.example.eightyage.domain.product.dto.response; + +import com.example.eightyage.domain.product.category.Category; +import com.example.eightyage.domain.product.salestate.SaleState; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +@AllArgsConstructor +public class ProductUpdateResponseDto { + private final String productName; + private final Integer price; + private final String content; + private final Category category; + private final SaleState saleState; + private final LocalDateTime createdAt; + private final LocalDateTime modifiedAt; +} diff --git a/src/main/java/com/example/eightyage/domain/product/entity/.gitkeep b/src/main/java/com/example/eightyage/domain/product/entity/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/example/eightyage/domain/product/entity/Product.java b/src/main/java/com/example/eightyage/domain/product/entity/Product.java new file mode 100644 index 0000000..7d547f1 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/product/entity/Product.java @@ -0,0 +1,95 @@ +package com.example.eightyage.domain.product.entity; + +import com.example.eightyage.domain.product.category.Category; +import com.example.eightyage.domain.product.salestate.SaleState; +import com.example.eightyage.domain.review.entity.Review; +import com.example.eightyage.global.entity.TimeStamped; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "product", + indexes = @Index(name = "index_saleState_category_name", columnList = "saleState, category, name") +) +public class Product extends TimeStamped { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + @Enumerated(EnumType.STRING) + private Category category; + + private String content; + + private Integer price; + + @Enumerated(EnumType.STRING) + private SaleState saleState; + + @OneToMany(mappedBy = "product") + private List reviews = new ArrayList<>(); + + @Temporal(TemporalType.TIMESTAMP) + @Column(nullable = true) + private LocalDateTime deletedAt; + + public Product(Long id) { + this.id = id; + } + + @Builder + public Product(String name, Category category, String content, Integer price, SaleState saleState) { + this.name = name; + this.category = category; + this.content = content; + this.price = price; + this.saleState = saleState; + } + + public void updateNameIfNotNull(String newName){ + if(newName != null){ + this.name = newName; + } + } + + public void updateCategoryIfNotNull(Category newCategory) { + if (newCategory != null) { + this.category = newCategory; + } + } + + public void updateContentIfNotNull(String newContent) { + if (newContent != null) { + this.content = newContent; + } + } + + public void updatePriceIfNotNull(Integer newPrice) { + if (newPrice != null) { + this.price = newPrice; + } + } + + public void updateSaleStateIfNotNull(SaleState newSaleState) { + if (newSaleState != null) { + this.saleState = newSaleState; + } + } + + public void deleteProduct() { + this.deletedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/example/eightyage/domain/product/entity/ProductImage.java b/src/main/java/com/example/eightyage/domain/product/entity/ProductImage.java new file mode 100644 index 0000000..2973890 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/product/entity/ProductImage.java @@ -0,0 +1,28 @@ +package com.example.eightyage.domain.product.entity; + +import com.example.eightyage.global.entity.TimeStamped; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "product_image") +public class ProductImage extends TimeStamped { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id") + private Product product; + + private String imageUrl; + + public ProductImage(Product product, String imageUrl) { + this.product = product; + this.imageUrl = imageUrl; + } +} diff --git a/src/main/java/com/example/eightyage/domain/product/repository/.gitkeep b/src/main/java/com/example/eightyage/domain/product/repository/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/example/eightyage/domain/product/repository/ProductBulkRepository.java b/src/main/java/com/example/eightyage/domain/product/repository/ProductBulkRepository.java new file mode 100644 index 0000000..89ae881 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/product/repository/ProductBulkRepository.java @@ -0,0 +1,29 @@ +package com.example.eightyage.domain.product.repository; + +import com.example.eightyage.domain.product.category.Category; +import com.example.eightyage.domain.product.entity.Product; +import com.example.eightyage.domain.product.salestate.SaleState; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Random; + +@Repository +@RequiredArgsConstructor +public class ProductBulkRepository { + + private final JdbcTemplate jdbcTemplate; + private final int BATCH_SIZE = 50; + + public void bulkInsertProduct(List products) { + String sql = "INSERT INTO product (category, name, sale_state) values (?, ?, ?)"; + + jdbcTemplate.batchUpdate(sql, products, BATCH_SIZE, (ps, argument) -> { + ps.setString(1, argument.getCategory().name()); + ps.setString(2, argument.getName()); + ps.setString(3, argument.getSaleState().name()); + }); + } +} diff --git a/src/main/java/com/example/eightyage/domain/product/repository/ProductImageRepository.java b/src/main/java/com/example/eightyage/domain/product/repository/ProductImageRepository.java new file mode 100644 index 0000000..9589344 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/product/repository/ProductImageRepository.java @@ -0,0 +1,17 @@ +package com.example.eightyage.domain.product.repository; + +import com.example.eightyage.domain.product.entity.ProductImage; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; +import java.util.Optional; + +public interface ProductImageRepository extends JpaRepository { + + @Query("SELECT pi FROM ProductImage pi WHERE pi.id = :imageId") + Optional findById(Long imageId); + + @Query("SELECT pi.imageUrl FROM ProductImage pi WHERE pi.product.id = :productId") + List findProductImageByProductId(Long productId); +} diff --git a/src/main/java/com/example/eightyage/domain/product/repository/ProductRepository.java b/src/main/java/com/example/eightyage/domain/product/repository/ProductRepository.java new file mode 100644 index 0000000..90baefc --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/product/repository/ProductRepository.java @@ -0,0 +1,33 @@ +package com.example.eightyage.domain.product.repository; + +import com.example.eightyage.domain.product.dto.response.ProductSearchResponseDto; +import com.example.eightyage.domain.product.category.Category; +import com.example.eightyage.domain.product.entity.Product; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface ProductRepository extends JpaRepository { + + @Query("SELECT p FROM Product p WHERE p.id = :productId AND p.deletedAt IS NULL") + Optional findById(@Param("productId") Long productId); + + @Query("SELECT new com.example.eightyage.domain.product.dto.response.ProductSearchResponseDto(p.name, p.category, p.price, AVG(r.score)) " + + "FROM Product p LEFT JOIN p.reviews r " + + "WHERE p.saleState = 'FOR_SALE' " + + "AND (:category IS NULL OR p.category = :category) " + + "AND (:name IS NULL OR p.name LIKE CONCAT('%', :name, '%')) " + + "GROUP BY p.name, p.category, p.price " + + "ORDER BY AVG(r.score)") + Page findProductsOrderByReviewScore( + @Param("name") String name, + @Param("category") Category category, + Pageable pageable + ); +} diff --git a/src/main/java/com/example/eightyage/domain/product/salestate/SaleState.java b/src/main/java/com/example/eightyage/domain/product/salestate/SaleState.java new file mode 100644 index 0000000..7445bbb --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/product/salestate/SaleState.java @@ -0,0 +1,6 @@ +package com.example.eightyage.domain.product.salestate; + +public enum SaleState { + FOR_SALE, + SOLD_OUT +} diff --git a/src/main/java/com/example/eightyage/domain/product/service/.gitkeep b/src/main/java/com/example/eightyage/domain/product/service/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/example/eightyage/domain/product/service/ProductImageService.java b/src/main/java/com/example/eightyage/domain/product/service/ProductImageService.java new file mode 100644 index 0000000..0a3599c --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/product/service/ProductImageService.java @@ -0,0 +1,75 @@ +package com.example.eightyage.domain.product.service; + +import com.example.eightyage.domain.product.entity.Product; +import com.example.eightyage.domain.product.entity.ProductImage; +import com.example.eightyage.domain.product.repository.ProductImageRepository; +import com.example.eightyage.global.exception.NotFoundException; +import com.example.eightyage.global.exception.ProductImageUploadException; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.io.IOException; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class ProductImageService { + + private final S3Client s3Client; + private final ProductImageRepository productImageRepository; + private final ProductService productService; + + private static final String BUCKET_NAME = "my-gom-bucket"; + private static final String REGION = "ap-northeast-2"; + + // ์ œํ’ˆ ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ + @Transactional + public String uploadImage(Long productId, MultipartFile file) { + try{ + String fileName = UUID.randomUUID() + "_" + file.getOriginalFilename(); // ํŒŒ์ผ๋ช… ์ค‘๋ณต ๋ฐฉ์ง€ + + // S3์— ์—…๋กœ๋“œ + s3Client.putObject( + PutObjectRequest.builder() + .bucket(BUCKET_NAME) + .key(fileName) + .contentType(file.getContentType()) + .build(), + RequestBody.fromInputStream(file.getInputStream(), file.getSize()) + ); + + // S3 ์ด๋ฏธ์ง€ URL ์ƒ์„ฑ + String imageUrl = String.format("https://%s.s3.%s.amazonaws.com/%s", BUCKET_NAME, REGION, fileName); + + // DB ์ €์žฅ + Product product = productService.findProductByIdOrElseThrow(productId); + ProductImage productImage = new ProductImage(product, imageUrl); + productImageRepository.save(productImage); + + return imageUrl; + } catch (IOException e) { + throw new ProductImageUploadException("์ด๋ฏธ์ง€ ์—…๋กœ๋“œ๋ฅผ ์‹คํŒจํ•˜์˜€์Šต๋‹ˆ๋‹ค: " + e.getMessage(), e); + } + } + + // ์ œํ’ˆ ์ด๋ฏธ์ง€ ์‚ญ์ œ + @Transactional + public void deleteImage(Long imageId) { + ProductImage findProductImage = findProductImageByIdOrElseThrow(imageId); + + productImageRepository.delete(findProductImage); + } + + public ProductImage findProductImageByIdOrElseThrow(Long imageId){ + return productImageRepository.findById(imageId).orElseThrow( + () -> new NotFoundException("ํ•ด๋‹น ์ด๋ฏธ์ง€๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.") + ); + } +} + diff --git a/src/main/java/com/example/eightyage/domain/product/service/ProductService.java b/src/main/java/com/example/eightyage/domain/product/service/ProductService.java new file mode 100644 index 0000000..4faca5d --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/product/service/ProductService.java @@ -0,0 +1,156 @@ +package com.example.eightyage.domain.product.service; + +import com.example.eightyage.domain.product.dto.request.ProductSaveRequestDto; +import com.example.eightyage.domain.product.dto.request.ProductUpdateRequestDto; +import com.example.eightyage.domain.product.dto.response.*; +import com.example.eightyage.domain.product.category.Category; +import com.example.eightyage.domain.product.entity.Product; +import com.example.eightyage.domain.product.entity.ProductImage; +import com.example.eightyage.domain.product.repository.ProductImageRepository; +import com.example.eightyage.domain.product.salestate.SaleState; +import com.example.eightyage.domain.product.repository.ProductRepository; +import com.example.eightyage.domain.review.entity.Review; +import com.example.eightyage.domain.review.repository.ReviewRepository; +import com.example.eightyage.domain.search.service.v1.SearchServiceV1; +import com.example.eightyage.domain.search.service.v2.SearchServiceV2; +import com.example.eightyage.domain.search.service.v3.SearchServiceV3; +import com.example.eightyage.global.exception.NotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.List; + + +@Service +@RequiredArgsConstructor +public class ProductService { + + private final ProductRepository productRepository; + private final ReviewRepository reviewRepository; + private final ProductImageRepository productImageRepository; + private final SearchServiceV1 searchServiceV1; + private final SearchServiceV2 searchServiceV2; + private final SearchServiceV3 searchServiceV3; + + // ์ œํ’ˆ ์ƒ์„ฑ + @Transactional + public ProductSaveResponseDto saveProduct(ProductSaveRequestDto requestDto) { + Product product = new Product(requestDto.getProductName(), requestDto.getCategory(), requestDto.getContent(), requestDto.getPrice(), SaleState.FOR_SALE); + + Product savedProduct = productRepository.save(product); + + return ProductSaveResponseDto.builder() + .productName(savedProduct.getName()) + .category(savedProduct.getCategory()) + .price(savedProduct.getPrice()) + .content(savedProduct.getContent()) + .saleState(savedProduct.getSaleState()) + .createdAt(savedProduct.getCreatedAt()) + .modifiedAt(savedProduct.getModifiedAt()) + .build(); + } + + // ์ œํ’ˆ ์ˆ˜์ • + @Transactional + public ProductUpdateResponseDto updateProduct(Long productId, ProductUpdateRequestDto requestDto) { + Product findProduct = findProductByIdOrElseThrow(productId); + + findProduct.updateNameIfNotNull(requestDto.getProductName()); + findProduct.updateCategoryIfNotNull(requestDto.getCategory()); + findProduct.updateContentIfNotNull(requestDto.getContent()); + findProduct.updateSaleStateIfNotNull(requestDto.getSaleState()); + findProduct.updatePriceIfNotNull(requestDto.getPrice()); + + return ProductUpdateResponseDto.builder() + .productName(findProduct.getName()) + .category(findProduct.getCategory()) + .price(findProduct.getPrice()) + .content(findProduct.getContent()) + .saleState(findProduct.getSaleState()) + .createdAt(findProduct.getCreatedAt()) + .modifiedAt(findProduct.getModifiedAt()) + .build(); + } + + // ์ œํ’ˆ ๋‹จ๊ฑด ์กฐํšŒ + @Transactional(readOnly = true) + public ProductGetResponseDto getProductById(Long productId) { + Product findProduct = findProductByIdOrElseThrow(productId); + List productImageList = productImageRepository.findProductImageByProductId(productId); + + return ProductGetResponseDto.builder() + .productName(findProduct.getName()) + .content(findProduct.getContent()) + .category(findProduct.getCategory()) + .price(findProduct.getPrice()) + .saleState(findProduct.getSaleState()) + .productImageList(productImageList) + .createdAt(findProduct.getCreatedAt()) + .modifiedAt(findProduct.getModifiedAt()) + .build(); + } + + // ์ œํ’ˆ ๋‹ค๊ฑด ์กฐํšŒ version 1 + @Transactional(readOnly = true) + public Page getProductsV1(String productName, Category category, int size, int page) { + int adjustedPage = Math.max(0, page - 1); + Pageable pageable = PageRequest.of(adjustedPage, size); + Page productsResponse = productRepository.findProductsOrderByReviewScore(productName, category, pageable); + + if (StringUtils.hasText(productName) && !productsResponse.isEmpty()) { + searchServiceV1.saveSearchLog(productName); // ๋กœ๊ทธ ์ €์žฅ + } + return productsResponse; + } + + // ์ œํ’ˆ ๋‹ค๊ฑด ์กฐํšŒ version 2 + @Transactional(readOnly = true) + public Page getProductsV2(String productName, Category category, int size, int page) { + int adjustedPage = Math.max(0, page - 1); + Pageable pageable = PageRequest.of(adjustedPage, size); + Page productsResponse = productRepository.findProductsOrderByReviewScore(productName, category, pageable); + + if (StringUtils.hasText(productName) && !productsResponse.isEmpty()) { + searchServiceV2.saveSearchLog(productName); // ๋กœ๊ทธ ์ €์žฅ + } + + return productsResponse; + } + + // ์ œํ’ˆ ๋‹ค๊ฑด ์กฐํšŒ version 3 + @Transactional(readOnly = true) + public Page getProductsV3(String productName, Category category, int size, int page) { + int adjustedPage = Math.max(0, page - 1); + Pageable pageable = PageRequest.of(adjustedPage, size); + Page productsResponse = productRepository.findProductsOrderByReviewScore(productName, category, pageable); + + if(StringUtils.hasText(productName) && !productsResponse.isEmpty()){ + searchServiceV3.saveSearchLog(productName); // ๋กœ๊ทธ ์ €์žฅ + searchServiceV3.increaseSortedKeywordRank(productName); // ์บ์‹œ ์ถ”๊ฐ€ + } + return productsResponse; + } + + // ์ œํ’ˆ ์‚ญ์ œ + @Transactional + public void deleteProduct(Long productId) { + Product findProduct = findProductByIdOrElseThrow(productId); + List findReviewList = reviewRepository.findReviewsByProductId(productId); + + reviewRepository.deleteAll(findReviewList); + + findProduct.deleteProduct(); + } + + public Product findProductByIdOrElseThrow(Long productId) { + return productRepository.findById(productId).orElseThrow( + () -> new NotFoundException("ํ•ด๋‹น ์ œํ’ˆ์ด ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.") + ); + } + +} diff --git a/src/main/java/com/example/eightyage/domain/review/controller/.gitkeep b/src/main/java/com/example/eightyage/domain/review/controller/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/example/eightyage/domain/review/controller/ReviewController.java b/src/main/java/com/example/eightyage/domain/review/controller/ReviewController.java new file mode 100644 index 0000000..ef1d780 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/review/controller/ReviewController.java @@ -0,0 +1,82 @@ +package com.example.eightyage.domain.review.controller; + +import com.example.eightyage.domain.review.dto.request.ReviewSaveRequestDto; +import com.example.eightyage.domain.review.dto.request.ReviewUpdateRequestDto; +import com.example.eightyage.domain.review.dto.response.ReviewSaveResponseDto; +import com.example.eightyage.domain.review.dto.response.ReviewUpdateResponseDto; +import com.example.eightyage.domain.review.dto.response.ReviewsGetResponseDto; +import com.example.eightyage.domain.review.service.ReviewService; +import com.example.eightyage.global.dto.AuthUser; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.annotation.Secured; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class ReviewController { + + private final ReviewService reviewService; + + // ๋ฆฌ๋ทฐ ์ƒ์„ฑ + @Secured("ROLE_USER") + @PostMapping("/v1/products/{productId}/reviews") + public ResponseEntity saveReview( + @AuthenticationPrincipal AuthUser authUser, + @PathVariable Long productId, + @Valid @RequestBody ReviewSaveRequestDto requestDto + ){ + ReviewSaveResponseDto responseDto = reviewService.saveReview(authUser.getUserId(), productId, requestDto); + + return new ResponseEntity<>(responseDto, HttpStatus.CREATED); + } + + // ๋ฆฌ๋ทฐ ์ˆ˜์ • + @Secured("ROLE_USER") + @PatchMapping("/v1/reviews/{reviewId}") + public ResponseEntity updateReview( + @AuthenticationPrincipal AuthUser authUser, + @PathVariable Long reviewId, + @RequestBody ReviewUpdateRequestDto requestDto + ){ + ReviewUpdateResponseDto responseDto = reviewService.updateReview(authUser.getUserId(), reviewId, requestDto); + + return ResponseEntity.ok(responseDto); + } + + // ๋ฆฌ๋ทฐ ๋‹ค๊ฑด ์กฐํšŒ + @GetMapping("/v1/reviews") + public ResponseEntity> findReviews( + @RequestParam(required = true) Long productId, + @RequestParam(required = false, defaultValue = "score") String orderBy, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size + ){ + PageRequest pageRequest = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, orderBy)); + + Page reviews = reviewService.getReviews(productId, pageRequest); + + return ResponseEntity.ok(reviews); + } + + // ๋ฆฌ๋ทฐ ์‚ญ์ œ + @Secured("ROLE_USER") + @DeleteMapping("/v1/reviews/{reviewId}") + public ResponseEntity deleteReview( + @AuthenticationPrincipal AuthUser authUser, + @PathVariable Long reviewId + ){ + reviewService.deleteReview(authUser.getUserId(), reviewId); + + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } +} diff --git a/src/main/java/com/example/eightyage/domain/review/dto/request/.gitkeep b/src/main/java/com/example/eightyage/domain/review/dto/request/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/example/eightyage/domain/review/dto/request/ReviewSaveRequestDto.java b/src/main/java/com/example/eightyage/domain/review/dto/request/ReviewSaveRequestDto.java new file mode 100644 index 0000000..5293830 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/review/dto/request/ReviewSaveRequestDto.java @@ -0,0 +1,18 @@ +package com.example.eightyage.domain.review.dto.request; + +import com.example.eightyage.global.dto.ValidationMessage; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ReviewSaveRequestDto { + + @NotNull(message = ValidationMessage.NOT_NULL_SCORE) + private Double score; + + @NotBlank(message = ValidationMessage.NOT_BLANK_CONTENT) + private String content; +} diff --git a/src/main/java/com/example/eightyage/domain/review/dto/request/ReviewUpdateRequestDto.java b/src/main/java/com/example/eightyage/domain/review/dto/request/ReviewUpdateRequestDto.java new file mode 100644 index 0000000..5f573ac --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/review/dto/request/ReviewUpdateRequestDto.java @@ -0,0 +1,12 @@ +package com.example.eightyage.domain.review.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +@Getter +@AllArgsConstructor +public class ReviewUpdateRequestDto { + private Double score; + private String content; +} \ No newline at end of file diff --git a/src/main/java/com/example/eightyage/domain/review/dto/response/.gitkeep b/src/main/java/com/example/eightyage/domain/review/dto/response/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/example/eightyage/domain/review/dto/response/ReviewSaveResponseDto.java b/src/main/java/com/example/eightyage/domain/review/dto/response/ReviewSaveResponseDto.java new file mode 100644 index 0000000..043cca4 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/review/dto/response/ReviewSaveResponseDto.java @@ -0,0 +1,21 @@ +package com.example.eightyage.domain.review.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +@AllArgsConstructor +public class ReviewSaveResponseDto { + private final Long id; + private final Long userId; + private final Long productId; + private final String nickname; + private final Double score; + private final String content; + private final LocalDateTime createdAt; + private final LocalDateTime modifiedAt; +} diff --git a/src/main/java/com/example/eightyage/domain/review/dto/response/ReviewUpdateResponseDto.java b/src/main/java/com/example/eightyage/domain/review/dto/response/ReviewUpdateResponseDto.java new file mode 100644 index 0000000..6350d3e --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/review/dto/response/ReviewUpdateResponseDto.java @@ -0,0 +1,20 @@ +package com.example.eightyage.domain.review.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +@AllArgsConstructor +public class ReviewUpdateResponseDto { + private final Long id; + private final Long userId; + private final String nickname; + private final Double score; + private final String content; + private final LocalDateTime createdAt; + private final LocalDateTime modifiedAt; +} \ No newline at end of file diff --git a/src/main/java/com/example/eightyage/domain/review/dto/response/ReviewsGetResponseDto.java b/src/main/java/com/example/eightyage/domain/review/dto/response/ReviewsGetResponseDto.java new file mode 100644 index 0000000..9ee2dff --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/review/dto/response/ReviewsGetResponseDto.java @@ -0,0 +1,20 @@ +package com.example.eightyage.domain.review.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +@AllArgsConstructor +public class ReviewsGetResponseDto { + private final Long id; + private final Long userId; + private final String nickname; + private final Double score; + private final String content; + private final LocalDateTime createdAt; + private final LocalDateTime modifiedAt; +} diff --git a/src/main/java/com/example/eightyage/domain/review/entity/.gitkeep b/src/main/java/com/example/eightyage/domain/review/entity/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/example/eightyage/domain/review/entity/Review.java b/src/main/java/com/example/eightyage/domain/review/entity/Review.java new file mode 100644 index 0000000..3af3698 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/review/entity/Review.java @@ -0,0 +1,53 @@ +package com.example.eightyage.domain.review.entity; + +import com.example.eightyage.domain.product.entity.Product; +import com.example.eightyage.domain.user.entity.User; +import com.example.eightyage.global.entity.TimeStamped; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "review") +public class Review extends TimeStamped { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id") + private Product product; + + private Double score; + + private String content; + + public Review(User user, Product product, Double score, String content) { + this.user = user; + this.product = product; + this.score = score; + this.content = content; + } + + public void updateScoreIfNotNull(Double newScore){ + if(newScore != null){ + this.score = newScore; + } + } + + public void updateContentIfNotNull(String newContent){ + if(newContent != null){ + this.content = newContent; + } + } +} diff --git a/src/main/java/com/example/eightyage/domain/review/repository/.gitkeep b/src/main/java/com/example/eightyage/domain/review/repository/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/example/eightyage/domain/review/repository/ReviewBulkRepository.java b/src/main/java/com/example/eightyage/domain/review/repository/ReviewBulkRepository.java new file mode 100644 index 0000000..c9bf85a --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/review/repository/ReviewBulkRepository.java @@ -0,0 +1,28 @@ +package com.example.eightyage.domain.review.repository; + +import com.example.eightyage.domain.review.entity.Review; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Random; + +@Repository +@RequiredArgsConstructor +public class ReviewBulkRepository { + + private final JdbcTemplate jdbcTemplate; + private final int BATCH_SIZE = 1000; + + public void bulkInsertReviews(List reviews) { + String sql = "INSERT INTO review (user_id, product_id, score) values (?, ?, ?)"; + + jdbcTemplate.batchUpdate(sql, reviews, BATCH_SIZE, (ps, argument) -> { + ps.setLong(1, argument.getUser().getId()); + ps.setLong(2, argument.getProduct().getId()); + ps.setDouble(3, argument.getScore()); + }); + } + +} diff --git a/src/main/java/com/example/eightyage/domain/review/repository/ReviewRepository.java b/src/main/java/com/example/eightyage/domain/review/repository/ReviewRepository.java new file mode 100644 index 0000000..fd3bd04 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/review/repository/ReviewRepository.java @@ -0,0 +1,27 @@ +package com.example.eightyage.domain.review.repository; + +import com.example.eightyage.domain.product.entity.Product; +import com.example.eightyage.domain.review.entity.Review; +import com.example.eightyage.global.exception.NotFoundException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface ReviewRepository extends JpaRepository { + + @Query("SELECT r FROM Review r WHERE r.id = :reviewId") + Optional findById(@Param("reviewId") Long reviewId); + + Page findByProductIdAndProductDeletedAtIsNull(Long productId, Pageable pageable); + + @Query("SELECT r FROM Review r JOIN FETCH r.user JOIN FETCH r.product WHERE r.product.id = :productId") + List findReviewsByProductId(@Param("productId") Long productId); +} diff --git a/src/main/java/com/example/eightyage/domain/review/service/.gitkeep b/src/main/java/com/example/eightyage/domain/review/service/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/example/eightyage/domain/review/service/ReviewService.java b/src/main/java/com/example/eightyage/domain/review/service/ReviewService.java new file mode 100644 index 0000000..f7616b2 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/review/service/ReviewService.java @@ -0,0 +1,115 @@ +package com.example.eightyage.domain.review.service; + +import com.example.eightyage.domain.product.dto.response.ProductUpdateResponseDto; +import com.example.eightyage.domain.product.entity.Product; +import com.example.eightyage.domain.product.repository.ProductRepository; +import com.example.eightyage.domain.product.service.ProductService; +import com.example.eightyage.domain.review.dto.request.ReviewSaveRequestDto; +import com.example.eightyage.domain.review.dto.request.ReviewUpdateRequestDto; +import com.example.eightyage.domain.review.dto.response.ReviewSaveResponseDto; +import com.example.eightyage.domain.review.dto.response.ReviewUpdateResponseDto; +import com.example.eightyage.domain.review.dto.response.ReviewsGetResponseDto; +import com.example.eightyage.domain.review.entity.Review; +import com.example.eightyage.domain.review.repository.ReviewRepository; +import com.example.eightyage.domain.user.entity.User; +import com.example.eightyage.domain.user.service.UserService; +import com.example.eightyage.global.exception.NotFoundException; +import com.example.eightyage.global.exception.UnauthorizedException; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ReviewService { + + private final ReviewRepository reviewRepository; + private final UserService userService; + private final ProductService productService; + + // ๋ฆฌ๋ทฐ ์ƒ์„ฑ + @Transactional + public ReviewSaveResponseDto saveReview(Long userId, Long productId, ReviewSaveRequestDto requestDto) { + User findUser = userService.findUserByIdOrElseThrow(userId); + Product findProduct = productService.findProductByIdOrElseThrow(productId); + + Review review = new Review(findUser, findProduct, requestDto.getScore(), requestDto.getContent()); + Review savedReview = reviewRepository.save(review); + + return ReviewSaveResponseDto.builder() + .id(savedReview.getId()) + .userId(savedReview.getUser().getId()) + .productId(savedReview.getProduct().getId()) + .nickname(savedReview.getUser().getNickname()) + .score(savedReview.getScore()) + .content(savedReview.getContent()) + .createdAt(savedReview.getCreatedAt()) + .modifiedAt(savedReview.getModifiedAt()) + .build(); + } + + // ๋ฆฌ๋ทฐ ์ˆ˜์ • + @Transactional + public ReviewUpdateResponseDto updateReview(Long userId, Long reviewId, ReviewUpdateRequestDto requestDto) { + User findUser = userService.findUserByIdOrElseThrow(userId); + Review findReview = findReviewByIdOrElseThrow(reviewId); + + if(findUser.getId().equals(findReview.getUser().getId())){ + findReview.updateScoreIfNotNull(requestDto.getScore()); + findReview.updateContentIfNotNull(requestDto.getContent()); + } else { + throw new UnauthorizedException("๋ฆฌ๋ทฐ๋ฅผ ์ˆ˜์ •ํ•  ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."); + } + + return ReviewUpdateResponseDto.builder() + .id(findReview.getId()) + .userId(findUser.getId()) + .nickname(findUser.getNickname()) + .score(findReview.getScore()) + .content(findReview.getContent()) + .createdAt(findReview.getCreatedAt()) + .modifiedAt(findReview.getModifiedAt()) + .build(); + } + + // ๋ฆฌ๋ทฐ ๋‹ค๊ฑด ์กฐํšŒ + @Transactional(readOnly = true) + public Page getReviews(Long productId, PageRequest pageRequest) { + Page reviewPage = reviewRepository.findByProductIdAndProductDeletedAtIsNull(productId, pageRequest); + + return reviewPage.map(review -> ReviewsGetResponseDto.builder() + .id(review.getId()) + .userId(review.getUser().getId()) + .nickname(review.getUser().getNickname()) + .score(review.getScore()) + .content(review.getContent()) + .createdAt(review.getCreatedAt()) + .modifiedAt(review.getModifiedAt()) + .build()); + } + + // ๋ฆฌ๋ทฐ ์‚ญ์ œ + @Transactional + public void deleteReview(Long userId, Long reviewId) { + User findUser = userService.findUserByIdOrElseThrow(userId); + Review findReview = findReviewByIdOrElseThrow(reviewId); + + if(!findUser.getId().equals(findReview.getUser().getId())){ + throw new UnauthorizedException("๋ฆฌ๋ทฐ๋ฅผ ์‚ญ์ œํ•  ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."); + } + + reviewRepository.delete(findReview); + } + + public Review findReviewByIdOrElseThrow(Long reviewId){ + return reviewRepository.findById(reviewId).orElseThrow( + () -> new NotFoundException("ํ•ด๋‹น ๋ฆฌ๋ทฐ๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.") + ); + } +} diff --git a/src/main/java/com/example/eightyage/domain/search/controller/SearchController.java b/src/main/java/com/example/eightyage/domain/search/controller/SearchController.java new file mode 100644 index 0000000..6ef8d33 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/search/controller/SearchController.java @@ -0,0 +1,46 @@ +package com.example.eightyage.domain.search.controller; + +import com.example.eightyage.domain.search.dto.PopularKeywordDto; +import com.example.eightyage.domain.search.service.v1.PopularKeywordServiceV1; +import com.example.eightyage.domain.search.service.v2.PopularKeywordServiceV2; +import com.example.eightyage.domain.search.service.v3.PopularKeywordServiceV3; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +public class SearchController { + + private final PopularKeywordServiceV1 popularKeywordServiceV1; + private final PopularKeywordServiceV2 popularKeywordServiceV2; + private final PopularKeywordServiceV3 popularKeywordServiceV3; + + // ์ธ๊ธฐ ๊ฒ€์ƒ‰์–ด ์กฐํšŒ (์บ์‹œ X) + @GetMapping("/api/v1/search/popular") + public ResponseEntity> searchPopularKeywords( + @RequestParam(defaultValue = "7") int days + ) { + return ResponseEntity.ok(popularKeywordServiceV1.searchPopularKeywords(days)); + } + + // ์ธ๊ธฐ ๊ฒ€์ƒ‰์–ด ์กฐํšŒ (์บ์‹œ O) + @GetMapping("/api/v2/search/popular") + public ResponseEntity> searchPopularKeywordsV2( + @RequestParam(defaultValue = "7") int days + ) { + return ResponseEntity.ok(popularKeywordServiceV2.searchPopularKeywords(days)); + } + + // ์‹ค์‹œ๊ฐ„ ์ธ๊ธฐ ๊ฒ€์ƒ‰์–ด ์กฐํšŒ (์บ์‹œ O) + @GetMapping("/api/v3/search/popular") + public ResponseEntity> searchPopularKeywordsV3( + @RequestParam(defaultValue = "10") int limits + ) { + return ResponseEntity.ok(popularKeywordServiceV3.searchPopularKeywords(limits)); + } +} diff --git a/src/main/java/com/example/eightyage/domain/search/dto/PopularKeywordDto.java b/src/main/java/com/example/eightyage/domain/search/dto/PopularKeywordDto.java new file mode 100644 index 0000000..b8db82b --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/search/dto/PopularKeywordDto.java @@ -0,0 +1,20 @@ +package com.example.eightyage.domain.search.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +@Setter +public class PopularKeywordDto { + + private String keyword; + private Long count; + + public static PopularKeywordDto keywordOf(String keyword, Long score) { + return new PopularKeywordDto(keyword, score); + } +} diff --git a/src/main/java/com/example/eightyage/domain/search/entity/SearchLog.java b/src/main/java/com/example/eightyage/domain/search/entity/SearchLog.java new file mode 100644 index 0000000..94981a9 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/search/entity/SearchLog.java @@ -0,0 +1,35 @@ +package com.example.eightyage.domain.search.entity; + +import jakarta.persistence.*; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@NoArgsConstructor +@Getter +@EntityListeners(AuditingEntityListener.class) +public class SearchLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String keyword; + + @CreatedDate + private LocalDateTime searchedAt; + + public static SearchLog keywordOf(String keyword) { + SearchLog log = new SearchLog(); + log.keyword = keyword; + return log; + } +} diff --git a/src/main/java/com/example/eightyage/domain/search/repository/SearchLogRepository.java b/src/main/java/com/example/eightyage/domain/search/repository/SearchLogRepository.java new file mode 100644 index 0000000..21c5bd1 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/search/repository/SearchLogRepository.java @@ -0,0 +1,20 @@ +package com.example.eightyage.domain.search.repository; + +import com.example.eightyage.domain.search.dto.PopularKeywordDto; +import com.example.eightyage.domain.search.entity.SearchLog; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; + +public interface SearchLogRepository extends JpaRepository { + + @Query("SELECT new com.example.eightyage.domain.search.dto.PopularKeywordDto(s.keyword, COUNT(s))" + + "FROM SearchLog s " + + "WHERE s.searchedAt >= :since " + + "GROUP BY s.keyword " + + "ORDER BY COUNT(s) DESC ") + List findPopularKeywords(@Param("since") LocalDateTime since); +} diff --git a/src/main/java/com/example/eightyage/domain/search/service/PopularKeywordService.java b/src/main/java/com/example/eightyage/domain/search/service/PopularKeywordService.java new file mode 100644 index 0000000..2118546 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/search/service/PopularKeywordService.java @@ -0,0 +1,9 @@ +package com.example.eightyage.domain.search.service; + +import com.example.eightyage.domain.search.dto.PopularKeywordDto; + +import java.util.List; + +public interface PopularKeywordService { + List searchPopularKeywords(int days); +} diff --git a/src/main/java/com/example/eightyage/domain/search/service/v1/PopularKeywordServiceV1.java b/src/main/java/com/example/eightyage/domain/search/service/v1/PopularKeywordServiceV1.java new file mode 100644 index 0000000..84f9429 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/search/service/v1/PopularKeywordServiceV1.java @@ -0,0 +1,32 @@ +package com.example.eightyage.domain.search.service.v1; + +import com.example.eightyage.domain.search.dto.PopularKeywordDto; +import com.example.eightyage.domain.search.repository.SearchLogRepository; +import com.example.eightyage.domain.search.service.PopularKeywordService; +import com.example.eightyage.global.exception.BadRequestException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class PopularKeywordServiceV1 implements PopularKeywordService { + + private final SearchLogRepository searchLogRepository; + private static final int MIN_DAYS = 1; + private static final int MAX_DAYS = 365; + + // ์บ์‹œX ์ธ๊ธฐ ๊ฒ€์ƒ‰์–ด ์กฐํšŒ + @Transactional(readOnly = true) + public List searchPopularKeywords(int days) { + if (days < MIN_DAYS || days > MAX_DAYS) { + throw new BadRequestException("์กฐํšŒ ๊ธฐ๊ฐ„์€ 1 ~ 365์ผ ์‚ฌ์ด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + LocalDateTime since = LocalDateTime.now().minusDays(days); + return searchLogRepository.findPopularKeywords(since); + } + +} \ No newline at end of file diff --git a/src/main/java/com/example/eightyage/domain/search/service/v1/SearchServiceV1.java b/src/main/java/com/example/eightyage/domain/search/service/v1/SearchServiceV1.java new file mode 100644 index 0000000..753104e --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/search/service/v1/SearchServiceV1.java @@ -0,0 +1,24 @@ +package com.example.eightyage.domain.search.service.v1; + +import com.example.eightyage.domain.search.entity.SearchLog; +import com.example.eightyage.domain.search.repository.SearchLogRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +@Service +@RequiredArgsConstructor +public class SearchServiceV1 { + + private final SearchLogRepository searchLogRepository; + + // ๊ฒ€์ƒ‰ ํ‚ค์›Œ๋“œ๋ฅผ ๋กœ๊ทธ์— ์ €์žฅ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void saveSearchLog(String keyword){ + if(StringUtils.hasText(keyword)){ + searchLogRepository.save(SearchLog.keywordOf(keyword)); + } + } +} diff --git a/src/main/java/com/example/eightyage/domain/search/service/v2/PopularKeywordServiceV2.java b/src/main/java/com/example/eightyage/domain/search/service/v2/PopularKeywordServiceV2.java new file mode 100644 index 0000000..83e2519 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/search/service/v2/PopularKeywordServiceV2.java @@ -0,0 +1,35 @@ +package com.example.eightyage.domain.search.service.v2; + +import com.example.eightyage.domain.search.dto.PopularKeywordDto; +import com.example.eightyage.domain.search.repository.SearchLogRepository; +import com.example.eightyage.domain.search.service.PopularKeywordService; +import com.example.eightyage.global.exception.BadRequestException; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class PopularKeywordServiceV2 implements PopularKeywordService { + + private final SearchLogRepository searchLogRepository; + private static final int MIN_DAYS = 1; + private static final int MAX_DAYS = 365; + private static final String POPULAR_KEYWORDS = "popularKeywords"; + + //์บ์‹œO ์ธ๊ธฐ ๊ฒ€์ƒ‰์–ด ์กฐํšŒ + @Transactional(readOnly = true) + @Cacheable(value = POPULAR_KEYWORDS, key = "#days") + public List searchPopularKeywords(int days) { + if (days < MIN_DAYS || days > MAX_DAYS) { + throw new BadRequestException("์กฐํšŒ ์ผ ์ˆ˜๋Š” 1~365 ์‚ฌ์ด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + LocalDateTime since = LocalDateTime.now().minusDays(days); + return searchLogRepository.findPopularKeywords(since); + } + +} diff --git a/src/main/java/com/example/eightyage/domain/search/service/v2/SearchServiceV2.java b/src/main/java/com/example/eightyage/domain/search/service/v2/SearchServiceV2.java new file mode 100644 index 0000000..333332c --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/search/service/v2/SearchServiceV2.java @@ -0,0 +1,25 @@ +package com.example.eightyage.domain.search.service.v2; + +import com.example.eightyage.domain.search.entity.SearchLog; +import com.example.eightyage.domain.search.repository.SearchLogRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + + +@Service +@RequiredArgsConstructor +public class SearchServiceV2 { + + private final SearchLogRepository searchLogRepository; + + // ๊ฒ€์ƒ‰ ํ‚ค์›Œ๋“œ๋ฅผ ๋กœ๊ทธ์— ์ €์žฅ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void saveSearchLog(String keyword) { + if (StringUtils.hasText(keyword)) { + searchLogRepository.save(SearchLog.keywordOf(keyword)); + } + } +} diff --git a/src/main/java/com/example/eightyage/domain/search/service/v3/PopularKeywordServiceV3.java b/src/main/java/com/example/eightyage/domain/search/service/v3/PopularKeywordServiceV3.java new file mode 100644 index 0000000..95afe83 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/search/service/v3/PopularKeywordServiceV3.java @@ -0,0 +1,34 @@ +package com.example.eightyage.domain.search.service.v3; + +import com.example.eightyage.domain.search.dto.PopularKeywordDto; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class PopularKeywordServiceV3 { + + private final RedisTemplate redisTemplate; + private static final String RANKING_KEY = "rankingPopularKeywords"; + + // ์ธ๊ธฐ ๊ฒ€์ƒ‰์–ด ์ƒ์œ„ N๊ฐœ ์กฐํšŒ + @Transactional(readOnly = true) + public List searchPopularKeywords(int limit) { + Set> keywordSet = + redisTemplate.opsForZSet().reverseRangeWithScores(RANKING_KEY, 0, limit - 1); + + if (keywordSet == null) { + return List.of(); + } + return keywordSet.stream().map(tuple -> PopularKeywordDto.keywordOf(tuple.getValue(), Objects.requireNonNull(tuple.getScore()).longValue())) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/example/eightyage/domain/search/service/v3/SearchServiceV3.java b/src/main/java/com/example/eightyage/domain/search/service/v3/SearchServiceV3.java new file mode 100644 index 0000000..384d11c --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/search/service/v3/SearchServiceV3.java @@ -0,0 +1,36 @@ +package com.example.eightyage.domain.search.service.v3; + +import com.example.eightyage.domain.search.entity.SearchLog; +import com.example.eightyage.domain.search.repository.SearchLogRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.Duration; + + +@Service +@RequiredArgsConstructor +public class SearchServiceV3 { + + private final SearchLogRepository searchLogRepository; + private final RedisTemplate redisTemplate; + private static final String RANKING_KEY = "rankingPopularKeywords"; + + // ๊ฒ€์ƒ‰ ํ‚ค์›Œ๋“œ๋ฅผ ๋กœ๊ทธ์— ์ €์žฅ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void saveSearchLog(String keyword) { + if (StringUtils.hasText(keyword)) { + searchLogRepository.save(SearchLog.keywordOf(keyword)); + } + } + + // ๊ฒ€์ƒ‰์–ด ์ ์ˆ˜ ์ฆ๊ฐ€ + public void increaseSortedKeywordRank(String productName) { + redisTemplate.opsForZSet().incrementScore(RANKING_KEY, productName, 1); + redisTemplate.expire(RANKING_KEY, Duration.ofMinutes(5)); + } +} diff --git a/src/main/java/com/example/eightyage/domain/user/controller/UserController.java b/src/main/java/com/example/eightyage/domain/user/controller/UserController.java new file mode 100644 index 0000000..86adf60 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/user/controller/UserController.java @@ -0,0 +1,28 @@ +package com.example.eightyage.domain.user.controller; + +import com.example.eightyage.domain.user.dto.request.UserDeleteRequestDto; +import com.example.eightyage.domain.user.service.UserService; +import com.example.eightyage.global.dto.AuthUser; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api") +public class UserController { + + private final UserService userService; + + /* ํšŒ์›ํƒˆํ‡ด */ + @PostMapping("/v1/users/delete") + public void signup( + @AuthenticationPrincipal AuthUser authUser, + @RequestBody UserDeleteRequestDto request + ) { + userService.deleteUser(authUser, request); + } +} diff --git a/src/main/java/com/example/eightyage/domain/user/dto/request/UserDeleteRequestDto.java b/src/main/java/com/example/eightyage/domain/user/dto/request/UserDeleteRequestDto.java new file mode 100644 index 0000000..3054ccd --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/user/dto/request/UserDeleteRequestDto.java @@ -0,0 +1,14 @@ +package com.example.eightyage.domain.user.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +public class UserDeleteRequestDto { + + private String password; + +} \ No newline at end of file diff --git a/src/main/java/com/example/eightyage/domain/user/entity/User.java b/src/main/java/com/example/eightyage/domain/user/entity/User.java new file mode 100644 index 0000000..a433f3c --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/user/entity/User.java @@ -0,0 +1,54 @@ +package com.example.eightyage.domain.user.entity; + +import com.example.eightyage.domain.user.userrole.UserRole; +import com.example.eightyage.global.dto.AuthUser; +import com.example.eightyage.global.entity.TimeStamped; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User extends TimeStamped { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true) + private String email; + + private String nickname; + + private String password; + + @Enumerated(EnumType.STRING) + private UserRole userRole; + + @Temporal(TemporalType.TIMESTAMP) + private LocalDateTime deletedAt; + + @Builder + public User(Long id, String email, String nickname, String password, UserRole userRole) { + this.id = id; + this.email = email; + this.nickname = nickname; + this.password = password; + this.userRole = userRole; + } + + public static User fromAuthUser(AuthUser authUser) { + return User.builder() + .id(authUser.getUserId()) + .email(authUser.getEmail()) + .nickname(authUser.getNickname()) + .userRole(UserRole.of(authUser.getAuthorities().iterator().next().getAuthority())) + .build(); + } + + public void deleteUser() { + this.deletedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/example/eightyage/domain/user/repository/UserBulkRepository.java b/src/main/java/com/example/eightyage/domain/user/repository/UserBulkRepository.java new file mode 100644 index 0000000..8c953a3 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/user/repository/UserBulkRepository.java @@ -0,0 +1,28 @@ +package com.example.eightyage.domain.user.repository; + +import com.example.eightyage.domain.user.entity.User; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Random; + +@Repository +@RequiredArgsConstructor +public class UserBulkRepository { + + private final JdbcTemplate jdbcTemplate; + private final int BATCH_SIZE = 1000; + + public void bulkInsertUsers(List users) { + String sql = "INSERT INTO user (email, password, nickname) values (?, ?, ?)"; + + jdbcTemplate.batchUpdate(sql, users, BATCH_SIZE, (ps, argument) -> { + ps.setString(1, argument.getEmail()); + ps.setString(2, argument.getPassword()); + ps.setString(3, argument.getNickname()); + }); + } +} diff --git a/src/main/java/com/example/eightyage/domain/user/repository/UserRepository.java b/src/main/java/com/example/eightyage/domain/user/repository/UserRepository.java new file mode 100644 index 0000000..fa2023e --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/user/repository/UserRepository.java @@ -0,0 +1,20 @@ +package com.example.eightyage.domain.user.repository; + +import com.example.eightyage.domain.user.entity.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.Optional; + +public interface UserRepository extends JpaRepository { + + boolean existsByEmail(String email); + + Optional findByEmail(@Param("email") String email); + + @Query("SELECT u FROM User u " + + "WHERE u.id = :userId " + + "AND u.deletedAt IS NULL") + Optional findById(@Param("userId") Long id); +} \ No newline at end of file diff --git a/src/main/java/com/example/eightyage/domain/user/service/UserService.java b/src/main/java/com/example/eightyage/domain/user/service/UserService.java new file mode 100644 index 0000000..8840f3d --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/user/service/UserService.java @@ -0,0 +1,68 @@ +package com.example.eightyage.domain.user.service; + +import com.example.eightyage.domain.user.dto.request.UserDeleteRequestDto; +import com.example.eightyage.domain.user.entity.User; +import com.example.eightyage.domain.user.userrole.UserRole; +import com.example.eightyage.domain.user.repository.UserRepository; +import com.example.eightyage.global.dto.AuthUser; +import com.example.eightyage.global.exception.BadRequestException; +import com.example.eightyage.global.exception.NotFoundException; +import com.example.eightyage.global.exception.UnauthorizedException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import static com.example.eightyage.global.exception.ErrorMessage.*; + +@Service +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + /* ํšŒ์›์ €์žฅ */ + @Transactional + public User saveUser(String email, String nickname, String password, String userRole) { + + if (userRepository.existsByEmail(email)) { + throw new BadRequestException(DUPLICATE_EMAIL.getMessage()); + } + + String encodedPassword = passwordEncoder.encode(password); + + User user = User.builder() + .email(email) + .nickname(nickname) + .password(encodedPassword) + .userRole(UserRole.of(userRole)) + .build(); + + return userRepository.save(user); + } + + /* ํšŒ์›ํƒˆํ‡ด */ + @Transactional + public void deleteUser(AuthUser authUser, UserDeleteRequestDto request) { + User findUser = findUserByIdOrElseThrow(authUser.getUserId()); + + if (!passwordEncoder.matches(request.getPassword(), findUser.getPassword())) { + throw new UnauthorizedException(INVALID_PASSWORD.getMessage()); + } + + findUser.deleteUser(); + } + + public User findUserByEmailOrElseThrow(String email) { + return userRepository.findByEmail(email).orElseThrow( + () -> new UnauthorizedException(USER_EMAIL_NOT_FOUND.getMessage()) + ); + } + + public User findUserByIdOrElseThrow(Long userId) { + return userRepository.findById(userId).orElseThrow( + () -> new NotFoundException(USER_ID_NOT_FOUND.getMessage()) + ); + } +} diff --git a/src/main/java/com/example/eightyage/domain/user/userrole/UserRole.java b/src/main/java/com/example/eightyage/domain/user/userrole/UserRole.java new file mode 100644 index 0000000..49b1fb1 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/user/userrole/UserRole.java @@ -0,0 +1,31 @@ +package com.example.eightyage.domain.user.userrole; + +import com.example.eightyage.global.exception.UnauthorizedException; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +import static com.example.eightyage.global.exception.ErrorMessage.NOT_INVALID_USER_ROLE; + +@Getter +@RequiredArgsConstructor +public enum UserRole { + + ROLE_USER(Authority.USER), + ROLE_ADMIN(Authority.ADMIN); + + private final String userRole; + + public static UserRole of(String role) { + return Arrays.stream(UserRole.values()) + .filter(r -> r.getUserRole().equalsIgnoreCase(role)) + .findFirst() + .orElseThrow(() -> new UnauthorizedException(NOT_INVALID_USER_ROLE.getMessage())); + } + + public static class Authority { + public static final String USER = "ROLE_USER"; + public static final String ADMIN = "ROLE_ADMIN"; + } +} diff --git a/src/main/java/com/example/eightyage/global/annotation/RefreshToken.java b/src/main/java/com/example/eightyage/global/annotation/RefreshToken.java new file mode 100644 index 0000000..537610d --- /dev/null +++ b/src/main/java/com/example/eightyage/global/annotation/RefreshToken.java @@ -0,0 +1,11 @@ +package com.example.eightyage.global.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface RefreshToken { +} diff --git a/src/main/java/com/example/eightyage/global/argument/RefreshArgumentResolver.java b/src/main/java/com/example/eightyage/global/argument/RefreshArgumentResolver.java new file mode 100644 index 0000000..c95cc79 --- /dev/null +++ b/src/main/java/com/example/eightyage/global/argument/RefreshArgumentResolver.java @@ -0,0 +1,47 @@ +package com.example.eightyage.global.argument; + +import com.example.eightyage.global.annotation.RefreshToken; +import com.example.eightyage.global.exception.UnauthorizedException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.core.MethodParameter; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import static com.example.eightyage.global.exception.ErrorMessage.REFRESH_TOKEN_MUST_BE_STRING; +import static com.example.eightyage.global.exception.ErrorMessage.REFRESH_TOKEN_NOT_FOUND; + +public class RefreshArgumentResolver implements HandlerMethodArgumentResolver { + @Override + public boolean supportsParameter(MethodParameter parameter) { + boolean hasRefreshTokenAnnotation = parameter.getParameterAnnotation(RefreshToken.class) != null; + boolean isStringType = parameter.getParameterType().equals(String.class); + + if (hasRefreshTokenAnnotation != isStringType) { + throw new UnauthorizedException(REFRESH_TOKEN_MUST_BE_STRING.getMessage()); + } + return hasRefreshTokenAnnotation; + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); + + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + if ("refreshToken".equals(cookie.getName())) { + return cookie.getValue(); + } + } + } + throw new UnauthorizedException(REFRESH_TOKEN_NOT_FOUND.getMessage()); + } +} diff --git a/src/main/java/com/example/eightyage/global/config/CacheConfig.java b/src/main/java/com/example/eightyage/global/config/CacheConfig.java new file mode 100644 index 0000000..7d3ed20 --- /dev/null +++ b/src/main/java/com/example/eightyage/global/config/CacheConfig.java @@ -0,0 +1,39 @@ +package com.example.eightyage.global.config; + +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; +import java.util.HashMap; +import java.util.Map; + +@EnableCaching +@Configuration +public class CacheConfig { + + @Bean + public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) { + // ๊ธฐ๋ณธ ์บ์‹œ ์„ค์ • + RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())); + + // ์บ์‹œ ๋ณ„๋กœ TTL ์„ค์ • + Map configMap = new HashMap<>(); + configMap.put("popularKeywords", defaultConfig.entryTtl(Duration.ofMinutes(5))); + + return RedisCacheManager.builder(redisConnectionFactory) + .cacheDefaults(defaultConfig) + .withInitialCacheConfigurations(configMap) + .build(); + } + +} diff --git a/src/main/java/com/example/eightyage/global/config/EnvConfig.java b/src/main/java/com/example/eightyage/global/config/EnvConfig.java new file mode 100644 index 0000000..f8dc6f4 --- /dev/null +++ b/src/main/java/com/example/eightyage/global/config/EnvConfig.java @@ -0,0 +1,21 @@ +//package com.example.eightyage.global.config; +// +//import io.github.cdimascio.dotenv.Dotenv; +//import jakarta.annotation.PostConstruct; +//import org.springframework.context.annotation.Configuration; +//import org.springframework.stereotype.Component; +// +//@Configuration +//public class EnvConfig { +// @PostConstruct +// public void init() { +// Dotenv dotenv = Dotenv.load(); +// +// System.setProperty("DB_URL", dotenv.get("DB_URL")); +// System.setProperty("DB_USER", dotenv.get("DB_USER")); +// System.setProperty("DB_PASSWORD", dotenv.get("DB_PASSWORD")); +// System.setProperty("DB_URL", dotenv.get("AWS_ACCESS_KEY")); +// System.setProperty("DB_USER", dotenv.get("AWS_SECRET_KEY")); +// System.setProperty("DB_PASSWORD", dotenv.get("JWT_SECRET_KEY")); +// } +//} diff --git a/src/main/java/com/example/eightyage/global/config/JwtAuthenticationToken.java b/src/main/java/com/example/eightyage/global/config/JwtAuthenticationToken.java new file mode 100644 index 0000000..b69aefa --- /dev/null +++ b/src/main/java/com/example/eightyage/global/config/JwtAuthenticationToken.java @@ -0,0 +1,25 @@ +package com.example.eightyage.global.config; + +import com.example.eightyage.global.dto.AuthUser; +import org.springframework.security.authentication.AbstractAuthenticationToken; + +public class JwtAuthenticationToken extends AbstractAuthenticationToken { + + private final AuthUser authUser; + + public JwtAuthenticationToken(AuthUser authUser) { + super(authUser.getAuthorities()); + this.authUser = authUser; + setAuthenticated(true); + } + + @Override + public Object getCredentials() { + return null; + } + + @Override + public Object getPrincipal() { + return authUser; + } +} diff --git a/src/main/java/com/example/eightyage/global/config/RedisConfig.java b/src/main/java/com/example/eightyage/global/config/RedisConfig.java new file mode 100644 index 0000000..8373a4a --- /dev/null +++ b/src/main/java/com/example/eightyage/global/config/RedisConfig.java @@ -0,0 +1,26 @@ +package com.example.eightyage.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + @Bean + public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) { + return new StringRedisTemplate(redisConnectionFactory); + } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + return redisTemplate; + } +} diff --git a/src/main/java/com/example/eightyage/global/config/RedissonConfig.java b/src/main/java/com/example/eightyage/global/config/RedissonConfig.java new file mode 100644 index 0000000..655646c --- /dev/null +++ b/src/main/java/com/example/eightyage/global/config/RedissonConfig.java @@ -0,0 +1,23 @@ +package com.example.eightyage.global.config; + +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.beans.factory.annotation.Value; + +@Configuration +public class RedissonConfig { + + @Value("${spring.data.redis.host}") + private String redisHost; + + @Bean + public RedissonClient redisson() { + Config config = new Config(); + config.useSingleServer() + .setAddress("redis://" + redisHost + ":6379"); + return Redisson.create(config); + } +} diff --git a/src/main/java/com/example/eightyage/global/config/S3Config.java b/src/main/java/com/example/eightyage/global/config/S3Config.java new file mode 100644 index 0000000..db42729 --- /dev/null +++ b/src/main/java/com/example/eightyage/global/config/S3Config.java @@ -0,0 +1,33 @@ +package com.example.eightyage.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +@Configuration +public class S3Config { + + private static final String REGION = "ap-northeast-2"; + + @Value("${AWS_ACCESS_KEY}") + private String accessKey; + + @Value("${AWS_SECRET_KEY}") + private String secretKey; + + + @Bean + public S3Client s3Client() { + return S3Client.builder() + .region(Region.of(REGION)) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey) + )) + .build(); + } +} + diff --git a/src/main/java/com/example/eightyage/global/config/SecurityConfig.java b/src/main/java/com/example/eightyage/global/config/SecurityConfig.java new file mode 100644 index 0000000..50ee441 --- /dev/null +++ b/src/main/java/com/example/eightyage/global/config/SecurityConfig.java @@ -0,0 +1,50 @@ +package com.example.eightyage.global.config; + +import com.example.eightyage.global.filter.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter; + +@Configuration +@RequiredArgsConstructor +@EnableWebSecurity +@EnableMethodSecurity(securedEnabled = true) +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .addFilterBefore(jwtAuthenticationFilter, SecurityContextHolderAwareRequestFilter.class) + .formLogin(AbstractHttpConfigurer::disable) + .anonymous(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) + .rememberMe(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(auth -> auth + .requestMatchers(request -> request.getRequestURI().startsWith("/api/v1/auth")).permitAll() + .requestMatchers("/actuator/**").permitAll() + .anyRequest().authenticated() + ) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/eightyage/global/config/WebConfig.java b/src/main/java/com/example/eightyage/global/config/WebConfig.java new file mode 100644 index 0000000..e07060f --- /dev/null +++ b/src/main/java/com/example/eightyage/global/config/WebConfig.java @@ -0,0 +1,18 @@ +package com.example.eightyage.global.config; + +import com.example.eightyage.global.argument.RefreshArgumentResolver; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + /* ArgumentResolver ๋“ฑ๋ก */ + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(new RefreshArgumentResolver()); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/eightyage/global/dto/AuthUser.java b/src/main/java/com/example/eightyage/global/dto/AuthUser.java new file mode 100644 index 0000000..07f565e --- /dev/null +++ b/src/main/java/com/example/eightyage/global/dto/AuthUser.java @@ -0,0 +1,27 @@ +package com.example.eightyage.global.dto; + +import com.example.eightyage.domain.user.userrole.UserRole; +import lombok.Builder; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import java.util.Collection; +import java.util.List; + +@Getter +public class AuthUser { + + private final Long userId; + private final String email; + private final String nickname; + private final Collection authorities; + + @Builder + public AuthUser(Long userId, String email, String nickname, UserRole role) { + this.userId = userId; + this.email = email; + this.nickname = nickname; + this.authorities = List.of(new SimpleGrantedAuthority(role.name())); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/eightyage/global/dto/ErrorResponse.java b/src/main/java/com/example/eightyage/global/dto/ErrorResponse.java new file mode 100644 index 0000000..37382de --- /dev/null +++ b/src/main/java/com/example/eightyage/global/dto/ErrorResponse.java @@ -0,0 +1,28 @@ +package com.example.eightyage.global.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.http.HttpStatus; + +@Getter +@Setter +@NoArgsConstructor +public class ErrorResponse { + + private String status; + + private Integer code; + + private T message; + + public ErrorResponse(HttpStatus httpStatus, T message) { + this.status = httpStatus.name(); + this.code = httpStatus.value(); + this.message = message; + } + + public static ErrorResponse of(HttpStatus httpStatus, T message) { + return new ErrorResponse<>(httpStatus, message); + } +} diff --git a/src/main/java/com/example/eightyage/global/dto/ValidationMessage.java b/src/main/java/com/example/eightyage/global/dto/ValidationMessage.java new file mode 100644 index 0000000..53e0351 --- /dev/null +++ b/src/main/java/com/example/eightyage/global/dto/ValidationMessage.java @@ -0,0 +1,26 @@ +package com.example.eightyage.global.dto; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class ValidationMessage { + + public static final String NOT_BLANK_EMAIL = "์ด๋ฉ”์ผ์€ ํ•„์ˆ˜ ์ž…๋ ฅ ๊ฐ’์ž…๋‹ˆ๋‹ค."; + public static final String PATTERN_EMAIL = "์ด๋ฉ”์ผ ํ˜•์‹์œผ๋กœ ์ž…๋ ฅ๋˜์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."; + public static final String NOT_BLANK_NICKNAME = "๋‹‰๋„ค์ž„์€ ํ•„์ˆ˜ ์ž…๋ ฅ ๊ฐ’์ž…๋‹ˆ๋‹ค."; + public static final String NOT_BLANK_PASSWORD = "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ํ•„์ˆ˜ ์ž…๋ ฅ ๊ฐ’์ž…๋‹ˆ๋‹ค."; + public static final String NOT_NULL_SCORE = "๋ณ„์ ์€ ํ•„์ˆ˜ ์ž…๋ ฅ ๊ฐ’์ž…๋‹ˆ๋‹ค."; + public static final String NOT_BLANK_CONTENT = "์ปจํ…ํŠธ๋Š” ํ•„์ˆ˜ ์ž…๋ ฅ ๊ฐ’์ž…๋‹ˆ๋‹ค."; + public static final String NOT_BLANK_PRODUCT_NAME = "์ƒํ’ˆ๋ช…์€ ํ•„์ˆ˜ ์ž…๋ ฅ ๊ฐ’์ž…๋‹ˆ๋‹ค."; + public static final String NOT_NULL_CATEGORY = "์นดํ…Œ๊ณ ๋ฆฌ๋Š” ํ•„์ˆ˜ ์ž…๋ ฅ ๊ฐ’์ž…๋‹ˆ๋‹ค."; + public static final String NOT_NULL_PRICE = "๊ฐ€๊ฒฉ์€ ํ•„์ˆ˜ ์ž…๋ ฅ ๊ฐ’์ž…๋‹ˆ๋‹ค."; + public static final String PATTERN_PASSWORD = "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ์˜์–ด, ์ˆซ์ž ํฌํ•จ 8์ž๋ฆฌ ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."; + public static final String PATTERN_PASSWORD_REGEXP = "^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d]{8,}$"; + public static final String NOT_BLANK_EVENT_NAME = "์ด๋ฒคํŠธ ์ด๋ฆ„์€ ํ•„์ˆ˜ ์ž…๋ ฅ ๊ฐ’์ž…๋‹ˆ๋‹ค."; + public static final String NOT_BLANK_EVENT_DESCRIPTION = "์ด๋ฒคํŠธ ์„ค๋ช…์€ ํ•„์ˆ˜ ์ž…๋ ฅ ๊ฐ’์ž…๋‹ˆ๋‹ค."; + public static final String INVALID_EVENT_QUANTITY = "์ˆ˜๋Ÿ‰์€ 1๊ฐœ ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."; + public static final String NOT_NULL_START_DATE = "์‹œ์ž‘ ๋‚ ์งœ๋Š” ํ•„์ˆ˜ ์ž…๋ ฅ ๊ฐ’์ž…๋‹ˆ๋‹ค."; + public static final String NOT_NULL_END_DATE = "์ข…๋ฃŒ ๋‚ ์งœ๋Š” ํ•„์ˆ˜ ์ž…๋ ฅ ๊ฐ’์ž…๋‹ˆ๋‹ค."; + +} \ No newline at end of file diff --git a/src/main/java/com/example/eightyage/global/entity/ErrorResponse.java b/src/main/java/com/example/eightyage/global/entity/ErrorResponse.java deleted file mode 100644 index e442661..0000000 --- a/src/main/java/com/example/eightyage/global/entity/ErrorResponse.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.example.eightyage.global.entity; - -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -@Getter -@Setter -@NoArgsConstructor -public class ErrorResponse { - - private String status; - - private Integer code; - - private String message; - - public ErrorResponse(String statusName, Integer code, String message) { - this.status = statusName; - this.code = code; - this.message = message; - } -} diff --git a/src/main/java/com/example/eightyage/global/entity/TimeStamped.java b/src/main/java/com/example/eightyage/global/entity/TimeStamped.java index 5b5e58d..75063c1 100644 --- a/src/main/java/com/example/eightyage/global/entity/TimeStamped.java +++ b/src/main/java/com/example/eightyage/global/entity/TimeStamped.java @@ -2,6 +2,7 @@ import jakarta.persistence.*; import lombok.Getter; +import lombok.Setter; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; @@ -22,8 +23,4 @@ public abstract class TimeStamped { @Column @Temporal(TemporalType.TIMESTAMP) private LocalDateTime modifiedAt; - - @Column - @Temporal(TemporalType.TIMESTAMP) - private LocalDateTime deletedAt; } diff --git a/src/main/java/com/example/eightyage/global/exception/BadRequestException.java b/src/main/java/com/example/eightyage/global/exception/BadRequestException.java new file mode 100644 index 0000000..ef8afb4 --- /dev/null +++ b/src/main/java/com/example/eightyage/global/exception/BadRequestException.java @@ -0,0 +1,12 @@ +package com.example.eightyage.global.exception; + +public class BadRequestException extends HandledException { + + public BadRequestException() { + super(ErrorCode.BAD_REQUEST); + } + + public BadRequestException(String message) { + super(ErrorCode.BAD_REQUEST, message); + } +} diff --git a/src/main/java/com/example/eightyage/global/exception/ErrorCode.java b/src/main/java/com/example/eightyage/global/exception/ErrorCode.java new file mode 100644 index 0000000..a9a2fc6 --- /dev/null +++ b/src/main/java/com/example/eightyage/global/exception/ErrorCode.java @@ -0,0 +1,22 @@ +package com.example.eightyage.global.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +import static com.example.eightyage.global.exception.ErrorMessage.*; + +@Getter +public enum ErrorCode { + AUTHORIZATION(HttpStatus.UNAUTHORIZED, DEFAULT_UNAUTHORIZED.getMessage()), + BAD_REQUEST(HttpStatus.BAD_REQUEST, DEFAULT_BAD_REQUEST.getMessage()), + NOT_FOUND(HttpStatus.NOT_FOUND, DEFAULT_NOT_FOUND.getMessage()), + FORBIDDEN(HttpStatus.FORBIDDEN, DEFAULT_FORBIDDEN.getMessage()); + + private final HttpStatus status; + private final String defaultMessage; + + ErrorCode(HttpStatus status, String defaultMessage) { + this.status = status; + this.defaultMessage = defaultMessage; + } +} diff --git a/src/main/java/com/example/eightyage/global/exception/ErrorMessage.java b/src/main/java/com/example/eightyage/global/exception/ErrorMessage.java new file mode 100644 index 0000000..ee23e98 --- /dev/null +++ b/src/main/java/com/example/eightyage/global/exception/ErrorMessage.java @@ -0,0 +1,43 @@ +package com.example.eightyage.global.exception; + +import lombok.Getter; + +@Getter +public enum ErrorMessage { + NOT_INVALID_USER_ROLE("์œ ํšจํ•˜์ง€ ์•Š์€ UserRole"), + NOT_FOUND_TOKEN("ํ† ํฐ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + PASSWORD_CONFIRMATION_MISMATCH("๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๋น„๋ฐ€๋ฒˆํ˜ธ ํ™•์ธ๊ณผ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."), + DEACTIVATED_USER_EMAIL("ํƒˆํ‡ดํ•œ ์œ ์ € ์ด๋ฉ”์ผ์ž…๋‹ˆ๋‹ค."), + INVALID_PASSWORD("๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."), + EXPIRED_REFRESH_TOKEN("์‚ฌ์šฉ์ด ๋งŒ๋ฃŒ๋œ refresh token ์ž…๋‹ˆ๋‹ค."), + REFRESH_TOKEN_NOT_FOUND("๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + DUPLICATE_EMAIL("๋“ฑ๋ก๋œ ์ด๋ฉ”์ผ์ž…๋‹ˆ๋‹ค."), + USER_EMAIL_NOT_FOUND("๊ฐ€์ž…ํ•œ ์œ ์ €์˜ ์ด๋ฉ”์ผ์ด ์•„๋‹™๋‹ˆ๋‹ค."), + USER_ID_NOT_FOUND("ํ•ด๋‹น ์œ ์ €์˜ Id๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + REFRESH_TOKEN_MUST_BE_STRING("@RefreshToken๊ณผ String ํƒ€์ž…์€ ํ•จ๊ป˜ ์‚ฌ์šฉ๋˜์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."), + + DEFAULT_UNAUTHORIZED("์ธ์ฆ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."), + DEFAULT_BAD_REQUEST("์ž˜๋ชป๋œ ์š”์ฒญ์ž…๋‹ˆ๋‹ค."), + DEFAULT_NOT_FOUND("์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค."), + DEFAULT_FORBIDDEN("๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."), + INTERNAL_SERVER_ERROR("์„œ๋ฒ„ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."), + + INVALID_JWT_SIGNATURE("์œ ํšจํ•˜์ง€ ์•Š๋Š” JWT ์„œ๋ช…์ž…๋‹ˆ๋‹ค."), + EXPIRED_JWT_TOKEN("๋งŒ๋ฃŒ๋œ JWT ํ† ํฐ์ž…๋‹ˆ๋‹ค."), + UNSUPPORTED_JWT_TOKEN("์ง€์›๋˜์ง€ ์•Š๋Š” JWT ํ† ํฐ์ž…๋‹ˆ๋‹ค."), + + EVENT_NOT_FOUND("์ด๋ฒคํŠธ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + INVALID_EVENT_PERIOD("์ด๋ฒคํŠธ ๊ธฐ๊ฐ„์ด ์•„๋‹™๋‹ˆ๋‹ค."), + CAN_NOT_ACCESS("์ž ์‹œํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”"), + COUPON_ALREADY_ISSUED("์ด๋ฏธ ์ฟ ํฐ ๋ฐœ๊ธ‰ ๋ฐ›์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค."), + COUPON_OUT_OF_STOCK("์ฟ ํฐ ์ˆ˜๋Ÿ‰์ด ์†Œ์ง„๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + COUPON_NOT_FOUND("์ฟ ํฐ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + COUPON_ALREADY_USED("์ด๋ฏธ ์‚ฌ์šฉ๋œ ์ฟ ํฐ์ž…๋‹ˆ๋‹ค."), + COUPON_FORBIDDEN("๋ณธ์ธ์˜ ์ฟ ํฐ์ด ์•„๋‹™๋‹ˆ๋‹ค."); + + private final String message; + + ErrorMessage(String message) { + this.message = message; + } +} diff --git a/src/main/java/com/example/eightyage/global/exception/ForbiddenException.java b/src/main/java/com/example/eightyage/global/exception/ForbiddenException.java new file mode 100644 index 0000000..9d500a9 --- /dev/null +++ b/src/main/java/com/example/eightyage/global/exception/ForbiddenException.java @@ -0,0 +1,12 @@ +package com.example.eightyage.global.exception; + +public class ForbiddenException extends HandledException { + + public ForbiddenException() { + super(ErrorCode.FORBIDDEN); + } + + public ForbiddenException(String message) { + super(ErrorCode.FORBIDDEN,message); + } +} diff --git a/src/main/java/com/example/eightyage/global/exception/GlobalExceptionHandler.java b/src/main/java/com/example/eightyage/global/exception/GlobalExceptionHandler.java index 2a7de39..1afcdac 100644 --- a/src/main/java/com/example/eightyage/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/example/eightyage/global/exception/GlobalExceptionHandler.java @@ -1,18 +1,57 @@ package com.example.eightyage.global.exception; +import com.example.eightyage.global.dto.ErrorResponse; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; -import java.util.Map; +import java.util.List; +import static com.example.eightyage.global.exception.ErrorMessage.DEFAULT_FORBIDDEN; +import static com.example.eightyage.global.exception.ErrorMessage.INTERNAL_SERVER_ERROR; + +@Slf4j @RestControllerAdvice public class GlobalExceptionHandler { - @ExceptionHandler(InvalidRequestException.class) - public ResponseEntity> invalidRequestExceptionException(InvalidRequestException ex) { - HttpStatus status = HttpStatus.BAD_REQUEST; - return getErrorResponse(status, ex.getMessage()); + @ExceptionHandler(HandledException.class) + public ResponseEntity> invalidRequestExceptionException(HandledException ex) { + HttpStatus httpStatus = ex.getHttpStatus(); + return new ResponseEntity<>(ErrorResponse.of(httpStatus, ex.getMessage()), ex.getHttpStatus()); + } + + @ResponseStatus(value = HttpStatus.BAD_REQUEST) + @ExceptionHandler + public ErrorResponse> handleValidationException(MethodArgumentNotValidException e) { + List fieldErrors = e.getBindingResult().getFieldErrors(); + + List validFailedList = fieldErrors.stream() + .map(error -> error.getField() + ": " + error.getDefaultMessage()) + .toList(); + return ErrorResponse.of(HttpStatus.BAD_REQUEST, validFailedList); + } + + @ResponseStatus(value = HttpStatus.FORBIDDEN) + @ExceptionHandler(AccessDeniedException.class) + public ErrorResponse handleAccessDeniedException() { + return ErrorResponse.of(HttpStatus.FORBIDDEN, DEFAULT_FORBIDDEN.getMessage()); + } + + @ResponseStatus(value = HttpStatus.INTERNAL_SERVER_ERROR) + @ExceptionHandler(Exception.class) + public ErrorResponse handleGlobalException(Exception e) { + log.error("Exception : {}",e.getMessage(), e); + return ErrorResponse.of(HttpStatus.INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR.getMessage()); + } + + @ExceptionHandler(ProductImageUploadException.class) + public ResponseEntity handleProductImageUploadException(ProductImageUploadException e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(e.getMessage()); } } diff --git a/src/main/java/com/example/eightyage/global/exception/HandledException.java b/src/main/java/com/example/eightyage/global/exception/HandledException.java new file mode 100644 index 0000000..ae7ce25 --- /dev/null +++ b/src/main/java/com/example/eightyage/global/exception/HandledException.java @@ -0,0 +1,25 @@ +package com.example.eightyage.global.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public class HandledException extends RuntimeException { + + private final HttpStatus httpStatus; + private final String message; + + // ์˜ˆ์™ธ ๋˜์งˆ์‹œ ๊ธฐ๋ณธ ๋ฉ”์„ธ์ง€ ์ถœ๋ ฅ + public HandledException(ErrorCode errorCode) { + super(errorCode.getDefaultMessage()); + this.httpStatus = errorCode.getStatus(); + this.message = errorCode.getDefaultMessage(); + } + + // ์˜ˆ์™ธ ๋˜์งˆ์‹œ ๋ฉ”์„ธ์ง€ ์ถœ๋ ฅ + public HandledException(ErrorCode errorCode, String message) { + super(message); + this.httpStatus = errorCode.getStatus(); + this.message = message; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/eightyage/global/exception/NotFoundException.java b/src/main/java/com/example/eightyage/global/exception/NotFoundException.java new file mode 100644 index 0000000..d3a2cc9 --- /dev/null +++ b/src/main/java/com/example/eightyage/global/exception/NotFoundException.java @@ -0,0 +1,12 @@ +package com.example.eightyage.global.exception; + +public class NotFoundException extends HandledException { + public NotFoundException() { + super(ErrorCode.NOT_FOUND); + } + + public NotFoundException(String message) { + super(ErrorCode.NOT_FOUND, message); + } +} + diff --git a/src/main/java/com/example/eightyage/global/exception/ProductImageUploadException.java b/src/main/java/com/example/eightyage/global/exception/ProductImageUploadException.java new file mode 100644 index 0000000..a12b4c0 --- /dev/null +++ b/src/main/java/com/example/eightyage/global/exception/ProductImageUploadException.java @@ -0,0 +1,7 @@ +package com.example.eightyage.global.exception; + +public class ProductImageUploadException extends RuntimeException { + public ProductImageUploadException(String message, Throwable cause){ + super(message, cause); + } +} diff --git a/src/main/java/com/example/eightyage/global/exception/UnauthorizedException.java b/src/main/java/com/example/eightyage/global/exception/UnauthorizedException.java new file mode 100644 index 0000000..2409f33 --- /dev/null +++ b/src/main/java/com/example/eightyage/global/exception/UnauthorizedException.java @@ -0,0 +1,11 @@ +package com.example.eightyage.global.exception; + +public class UnauthorizedException extends HandledException { + + public UnauthorizedException() { + super(ErrorCode.AUTHORIZATION); + } + public UnauthorizedException(String message) { + super(ErrorCode.AUTHORIZATION, message); + } +} diff --git a/src/main/java/com/example/eightyage/global/filter/.gitkeep b/src/main/java/com/example/eightyage/global/filter/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/example/eightyage/global/filter/JwtAuthenticationFilter.java b/src/main/java/com/example/eightyage/global/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..dccf118 --- /dev/null +++ b/src/main/java/com/example/eightyage/global/filter/JwtAuthenticationFilter.java @@ -0,0 +1,82 @@ +package com.example.eightyage.global.filter; + +import com.example.eightyage.domain.user.userrole.UserRole; +import com.example.eightyage.global.config.JwtAuthenticationToken; +import com.example.eightyage.global.util.JwtUtil; +import com.example.eightyage.global.dto.AuthUser; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +import static com.example.eightyage.global.exception.ErrorMessage.*; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + + String authorizationHeader = request.getHeader("Authorization"); + + if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { + String jwt = jwtUtil.substringToken(authorizationHeader); + + try { + Claims claims = jwtUtil.extractClaims(jwt); + + if (SecurityContextHolder.getContext().getAuthentication() == null) { + setAuthentication(claims); + } + + } catch (SecurityException | MalformedJwtException e) { + log.error("Invalid JWT signature, ์œ ํšจํ•˜์ง€ ์•Š๋Š” JWT ์„œ๋ช… ์ž…๋‹ˆ๋‹ค.", e); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, INVALID_JWT_SIGNATURE.getMessage()); + return; + } catch (ExpiredJwtException e) { + log.error("Expired JWT token, ๋งŒ๋ฃŒ๋œ JWT token ์ž…๋‹ˆ๋‹ค.", e); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, EXPIRED_JWT_TOKEN.getMessage()); + return; + } catch (UnsupportedJwtException e) { + log.error("Unsupported JWT token, ์ง€์›๋˜์ง€ ์•Š๋Š” JWT ํ† ํฐ ์ž…๋‹ˆ๋‹ค.", e); + response.sendError(HttpServletResponse.SC_BAD_REQUEST, UNSUPPORTED_JWT_TOKEN.getMessage()); + return; + } catch (Exception e) { + log.error(INTERNAL_SERVER_ERROR.getMessage(), e); + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + return; + } + } + filterChain.doFilter(request, response); + } + + private void setAuthentication(Claims claims) { + Long userId = Long.valueOf(claims.getSubject()); + String email = claims.get("email", String.class); + String nickname = claims.get("nickname", String.class); + UserRole userRole = UserRole.of(claims.get("userRole", String.class)); + + AuthUser authUser = new AuthUser(userId, email, nickname, userRole); + JwtAuthenticationToken authenticationToken = new JwtAuthenticationToken(authUser); + SecurityContextHolder.getContext().setAuthentication(authenticationToken); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/eightyage/global/util/.gitkeep b/src/main/java/com/example/eightyage/global/util/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/example/eightyage/global/util/JwtUtil.java b/src/main/java/com/example/eightyage/global/util/JwtUtil.java new file mode 100644 index 0000000..1716dd0 --- /dev/null +++ b/src/main/java/com/example/eightyage/global/util/JwtUtil.java @@ -0,0 +1,69 @@ +package com.example.eightyage.global.util; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import com.example.eightyage.domain.user.userrole.UserRole; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.rmi.ServerException; +import java.security.Key; +import java.util.Base64; +import java.util.Date; + +import static com.example.eightyage.global.exception.ErrorMessage.NOT_FOUND_TOKEN; + +@Slf4j(topic = "JwtUtil") +@Component +public class JwtUtil { + + private static final String BEARER_PREFIX = "Bearer "; + private static final long ACCESS_TOKEN_TIME = 10 * 60 * 1000L; // 10๋ถ„ + + @Value("${jwt.secret.key}") + private String secretKey; + private Key key; + private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256; + + @PostConstruct + public void init() { + byte[] bytes = Base64.getDecoder().decode(secretKey); + key = Keys.hmacShaKeyFor(bytes); + } + + public String createAccessToken(Long userId, String email, String nickname, UserRole userRole) { + Date date = new Date(); + + return BEARER_PREFIX + + Jwts.builder() + .setSubject(String.valueOf(userId)) + .claim("email", email) + .claim("nickname", nickname) + .claim("userRole", userRole) + .setExpiration(new Date(date.getTime() + ACCESS_TOKEN_TIME)) + .setIssuedAt(date) + .signWith(key, signatureAlgorithm) + .compact(); + } + + public String substringToken(String tokenValue) throws ServerException { + if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) { + return tokenValue.substring(7); + } + throw new ServerException(NOT_FOUND_TOKEN.getMessage()); + } + + public Claims extractClaims(String token) { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + } +} + diff --git a/src/main/java/com/example/eightyage/global/util/RandomCodeGenerator.java b/src/main/java/com/example/eightyage/global/util/RandomCodeGenerator.java new file mode 100644 index 0000000..dd8f121 --- /dev/null +++ b/src/main/java/com/example/eightyage/global/util/RandomCodeGenerator.java @@ -0,0 +1,18 @@ +package com.example.eightyage.global.util; + +import java.security.SecureRandom; + +public class RandomCodeGenerator { + + private static final String CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + private static final SecureRandom random = new SecureRandom(); + + public static String generateCouponCode(int length) { + StringBuilder sb = new StringBuilder(length); + for (int i = 0; i < length; i++) { + sb.append(CHARACTERS.charAt(random.nextInt(CHARACTERS.length()))); + } + return sb.toString(); + } +} + diff --git a/src/main/resources/application-ci.yml b/src/main/resources/application-ci.yml new file mode 100644 index 0000000..17459d6 --- /dev/null +++ b/src/main/resources/application-ci.yml @@ -0,0 +1,28 @@ +server: + port: 8080 + +spring: + datasource: + url: jdbc:mysql://localhost:3306/team8_test + username: root + password: root + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + hibernate: + ddl-auto: update + properties: + hibernate: + dialect: org.hibernate.dialect.MySQLDialect + show_sql: true + format_sql: true + + cloud: + aws: + credentials: + access-key: ${AWS_ACCESS_KEY} + secret-key: ${AWS_SECRET_KEY} + +jwt: + secret: + key: ${JWT_SECRET_KEY} \ No newline at end of file diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 0000000..84e09b8 --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,24 @@ +spring: + config: + import: optional:file:.env[.properties] + + cloud: + aws: + credentials: + access-key: ${AWS_ACCESS_KEY} + secret-key: ${AWS_SECRET_KEY} + region: + static: ap-northeast-2 + stack: + auto: false + + datasource: + url: ${DB_URL} + username: ${DB_USER} + password: ${DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + + data: + redis: + host: localhost + port: 6379 \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..3006731 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,50 @@ +server: + port: 8080 + servlet: + context-path: / + encoding: + charset: UTF-8 + enabled: true + force: true + session: + timeout: 1800 + +spring: + application: + name: eightyage + + data: + redis: + host: ${SPRING_DATA_REDIS_HOST} + port: 6379 + + datasource: + url: ${DB_URL} + username: ${DB_USER} + password: ${DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + hibernate: + ddl-auto: update + properties: + hibernate: + show_sql: false + format_sql: true + use_sql_comments: false + dialect: org.hibernate.dialect.MySQLDialect + +jwt: + secret: + key: ${JWT_SECRET_KEY} + +management: + endpoints: + web: + exposure: + include: health,info + endpoint: + health: + show-details: always + security: + enabled: false \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 99dc2c4..d4d6498 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -13,12 +13,6 @@ spring: application: name: eightyage - datasource: - url: ${DB_URL} - username: ${DB_USER} - password: ${DB_PASSWORD} - driver-class-name: com.mysql.cj.jdbc.Driver - jpa: hibernate: ddl-auto: update @@ -30,4 +24,5 @@ spring: dialect: org.hibernate.dialect.MySQLDialect jwt: - secret: ${JWT_SECRET_KEY} + secret: + key: ${JWT_SECRET_KEY} \ No newline at end of file diff --git a/src/test/java/com/example/eightyage/EightyageApplicationTests.java b/src/test/java/com/example/eightyage/EightyageApplicationTests.java deleted file mode 100644 index d2e270e..0000000 --- a/src/test/java/com/example/eightyage/EightyageApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.example.eightyage; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class EightyageApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/src/test/java/com/example/eightyage/bulk/ProductBulkTest.java b/src/test/java/com/example/eightyage/bulk/ProductBulkTest.java new file mode 100644 index 0000000..810f73d --- /dev/null +++ b/src/test/java/com/example/eightyage/bulk/ProductBulkTest.java @@ -0,0 +1,70 @@ +//package com.example.eightyage.bulk; +// +//import com.example.eightyage.domain.product.entity.Category; +//import com.example.eightyage.domain.product.entity.Product; +//import com.example.eightyage.domain.product.entity.SaleState; +//import com.example.eightyage.domain.product.repository.ProductBulkRepository; +//import org.junit.jupiter.api.Test; +//import org.springframework.beans.factory.annotation.Autowired; +//import org.springframework.boot.test.context.SpringBootTest; +//import org.springframework.test.context.ActiveProfiles; +// +//import java.util.ArrayList; +//import java.util.List; +//import java.util.Random; +//import java.util.UUID; +// +//@SpringBootTest +//@ActiveProfiles("ci") +//public class ProductBulkTest { +// +// @Autowired +// private ProductBulkRepository productBulkRepository; +// +// @Test +// void ์ œํ’ˆ_๋”๋ฏธ๋ฐ์ดํ„ฐ_์ƒ์„ฑ() { +// +// int insertCount; +// +// if ("ci".equals(System.getProperty("spring.profiles.active"))) { +// insertCount = 100; // CI์—์„œ๋Š” ๋ฐ์ดํ„ฐ ์ ๊ฒŒ +// } else { +// insertCount = 1000000; // ๋กœ์ปฌ, ๊ฐœ๋ฐœ ์„œ๋ฒ„ ๋“ฑ์—์„œ๋Š” ๋งŽ๊ฒŒ +// } +// +// Random random = new Random(); +// +// List batchList = new ArrayList<>(); +// +// for (int i = 0; i < insertCount; i++) { +// Category randomCategory = Category.values()[random.nextInt(Category.values().length)]; +// Product product = Product.builder() +// .category(randomCategory) +// .name(UUID.randomUUID().toString()) +// .saleState(random.nextBoolean() ? SaleState.FOR_SALE : SaleState.SOLD_OUT) +// .build(); +// +// batchList.add(product); +// +// if (batchList.size() == insertCount / 100) { +// productBulkRepository.bulkInsertProduct(batchList); +// batchList.clear(); +// +// sleep(500); +// } +// } +// +// if (!batchList.isEmpty()) { +// productBulkRepository.bulkInsertProduct(batchList); +// batchList.clear(); +// } +// } +// +// private static void sleep(int millis) { +// try { +// Thread.sleep(millis); +// } catch (InterruptedException e) { +// throw new RuntimeException(e); +// } +// } +//} diff --git a/src/test/java/com/example/eightyage/bulk/ReviewBulkTest.java b/src/test/java/com/example/eightyage/bulk/ReviewBulkTest.java new file mode 100644 index 0000000..9ecb4e1 --- /dev/null +++ b/src/test/java/com/example/eightyage/bulk/ReviewBulkTest.java @@ -0,0 +1,65 @@ +//package com.example.eightyage.bulk; +// +//import com.example.eightyage.domain.product.entity.Product; +//import com.example.eightyage.domain.review.entity.Review; +//import com.example.eightyage.domain.review.repository.ReviewBulkRepository; +//import com.example.eightyage.domain.user.entity.User; +//import org.junit.jupiter.api.Test; +//import org.springframework.beans.factory.annotation.Autowired; +//import org.springframework.boot.test.context.SpringBootTest; +//import org.springframework.test.context.ActiveProfiles; +// +//import java.util.ArrayList; +//import java.util.List; +//import java.util.Random; +// +//@SpringBootTest +//@ActiveProfiles("ci") +//public class ReviewBulkTest { +// +// @Autowired +// private ReviewBulkRepository reviewBulkRepository; +// +// @Test +// void ๋ฆฌ๋ทฐ_๋”๋ฏธ๋ฐ์ดํ„ฐ_์ƒ์„ฑ() { +// +// int insertCount; +// +// if ("ci".equals(System.getProperty("spring.profiles.active"))) { +// insertCount = 100; // CI์—์„œ๋Š” ๋ฐ์ดํ„ฐ ์ ๊ฒŒ +// } else { +// insertCount = 1000000; // ๋กœ์ปฌ, ๊ฐœ๋ฐœ ์„œ๋ฒ„ ๋“ฑ์—์„œ๋Š” ๋งŽ๊ฒŒ +// } +// +// Random random = new Random(); +// List batchList = new ArrayList<>(); +// +// for (int i = 0; i < insertCount; i++) { +// User user = User.builder().id(1L).build(); +// Product product = new Product((long) (random.nextInt(100) + 1)); +// +// Review review = new Review(user, product, random.nextDouble() * 5, "content" + i); +// batchList.add(review); +// +// if (batchList.size() == insertCount / 100) { +// reviewBulkRepository.bulkInsertReviews(batchList); +// batchList.clear(); +// +//// sleep(500); +// } +// } +// +// if (!batchList.isEmpty()) { +// reviewBulkRepository.bulkInsertReviews(batchList); +// batchList.clear(); +// } +// } +// +// private static void sleep(int millis) { +// try { +// Thread.sleep(millis); +// } catch (InterruptedException e) { +// throw new RuntimeException(e); +// } +// } +//} diff --git a/src/test/java/com/example/eightyage/bulk/UserBulkTest.java b/src/test/java/com/example/eightyage/bulk/UserBulkTest.java new file mode 100644 index 0000000..51ec056 --- /dev/null +++ b/src/test/java/com/example/eightyage/bulk/UserBulkTest.java @@ -0,0 +1,65 @@ +//package com.example.eightyage.bulk; +// +//import com.example.eightyage.domain.user.entity.User; +//import com.example.eightyage.domain.user.userrole.UserRole; +//import com.example.eightyage.domain.user.repository.UserBulkRepository; +//import com.example.eightyage.global.util.RandomCodeGenerator; +//import org.junit.jupiter.api.Test; +//import org.springframework.beans.factory.annotation.Autowired; +//import org.springframework.boot.test.context.SpringBootTest; +//import org.springframework.test.context.ActiveProfiles; +// +//import java.util.ArrayList; +//import java.util.List; +// +//@SpringBootTest +//@ActiveProfiles("ci") +//public class UserBulkTest { +// +// @Autowired +// private UserBulkRepository userBulkRepository; +// +// @Test +// void ์œ ์ €_๋ฐ์ดํ„ฐ_์ƒ์„ฑ() { +// +// int insertCount; +// +// if ("ci".equals(System.getProperty("spring.profiles.active"))) { +// insertCount = 100; // CI์—์„œ๋Š” ๋ฐ์ดํ„ฐ ์ ๊ฒŒ +// } else { +// insertCount = 1000000; // ๋กœ์ปฌ, ๊ฐœ๋ฐœ ์„œ๋ฒ„ ๋“ฑ์—์„œ๋Š” ๋งŽ๊ฒŒ +// } +// +// List batchList = new ArrayList<>(); +// +// for (int i = 0; i < insertCount; i++) { +// User user = User.builder() +// .email(RandomCodeGenerator.generateCouponCode(8) + "@email.com") +// .nickname("nickname" + i) +// .password("password") +// .userRole(UserRole.ROLE_USER) +// .build(); +// batchList.add(user); +// +// if (batchList.size() == insertCount / 100) { +// userBulkRepository.bulkInsertUsers(batchList); +// batchList.clear(); +// +//// sleep(500); +// } +// } +// +// if (!batchList.isEmpty()) { +// userBulkRepository.bulkInsertUsers(batchList); +// batchList.clear(); +// } +// } +// +// private static void sleep(int millis) { +// try { +// Thread.sleep(millis); +// } catch (InterruptedException e) { +// throw new RuntimeException(e); +// } +// } +//} diff --git a/src/test/java/com/example/eightyage/domain/auth/service/AuthServiceTest.java b/src/test/java/com/example/eightyage/domain/auth/service/AuthServiceTest.java new file mode 100644 index 0000000..7254b8e --- /dev/null +++ b/src/test/java/com/example/eightyage/domain/auth/service/AuthServiceTest.java @@ -0,0 +1,172 @@ +package com.example.eightyage.domain.auth.service; + +import com.example.eightyage.domain.auth.dto.request.AuthSigninRequestDto; +import com.example.eightyage.domain.auth.dto.request.AuthSignupRequestDto; +import com.example.eightyage.domain.auth.dto.response.AuthTokensResponseDto; +import com.example.eightyage.domain.user.entity.User; +import com.example.eightyage.domain.user.userrole.UserRole; +import com.example.eightyage.domain.user.service.UserService; +import com.example.eightyage.global.exception.BadRequestException; +import com.example.eightyage.global.exception.UnauthorizedException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; + +import static com.example.eightyage.global.exception.ErrorMessage.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +public class AuthServiceTest { + + @Mock + private UserService userService; + @Mock + private TokenService tokenService; + @Mock + private PasswordEncoder passwordEncoder; + + @InjectMocks + private AuthService authService; + + private AuthSignupRequestDto successSignupDto; + private AuthSignupRequestDto passwordCheckErrorSignupDto; + private AuthSigninRequestDto successSigninDto; + private User user; + + @BeforeEach + public void setUp() { + passwordCheckErrorSignupDto = AuthSignupRequestDto.builder() + .email("email@email.com") + .nickname("nickname") + .password("password1234") + .passwordCheck("password1234!") + .userRole("USER_ROLE") + .build(); + + successSignupDto = AuthSignupRequestDto.builder() + .email("email@email.com") + .nickname("nickname") + .password("password1234") + .passwordCheck("password1234") + .userRole("USER_ROLE") + .build(); + + successSigninDto = AuthSigninRequestDto.builder() + .email("email@email.com") + .password("password1234") + .build(); + + user = User.builder() + .email(successSignupDto.getEmail()) + .nickname(successSignupDto.getNickname()) + .userRole(UserRole.ROLE_USER) + .build(); + + } + + @Test + void ํšŒ์›๊ฐ€์ž…_๋น„๋ฐ€๋ฒˆํ˜ธ_ํ™•์ธ_๋ถˆ์ผ์น˜_์‹คํŒจ() { + // given + + // when & then + BadRequestException badRequestException = assertThrows(BadRequestException.class, + () -> authService.signup(passwordCheckErrorSignupDto)); + assertEquals(badRequestException.getMessage(), PASSWORD_CONFIRMATION_MISMATCH.getMessage()); + } + + @Test + void ํšŒ์›๊ฐ€์ž…_์„ฑ๊ณต() { + // given + String accessToken = "accessToken"; + String refreshToken = "refreshToken"; + + given(userService.saveUser(any(String.class), any(String.class), any(String.class), any(String.class))).willReturn(user); + given(tokenService.createAccessToken(any(User.class))).willReturn(accessToken); + given(tokenService.createRefreshToken(any(User.class))).willReturn(refreshToken); + + // when + AuthTokensResponseDto result = authService.signup(successSignupDto); + + // then + assertEquals(accessToken, result.getAccessToken()); + assertEquals(refreshToken, result.getRefreshToken()); + } + + @Test + void ๋กœ๊ทธ์ธ_์‚ญ์ œ๋œ_์œ ์ €์˜_์ด๋ฉ”์ผ์ผ_๊ฒฝ์šฐ_์‹คํŒจ() { + // given + ReflectionTestUtils.setField(user, "deletedAt", LocalDateTime.now()); + + given(userService.findUserByEmailOrElseThrow(any(String.class))).willReturn(user); + + // when & then + UnauthorizedException unauthorizedException = assertThrows(UnauthorizedException.class, + () -> authService.signin(successSigninDto)); + assertEquals(unauthorizedException.getMessage(), DEACTIVATED_USER_EMAIL.getMessage()); + } + + @Test + void ๋กœ๊ทธ์ธ_๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€_์ผ์น˜ํ•˜์ง€_์•Š์„_๊ฒฝ์šฐ_์‹คํŒจ() { + // given + ReflectionTestUtils.setField(user, "deletedAt", null); + + given(userService.findUserByEmailOrElseThrow(any(String.class))).willReturn(user); + given(passwordEncoder.matches(successSigninDto.getPassword(), user.getPassword())).willReturn(false); + + // when & then + UnauthorizedException unauthorizedException = assertThrows(UnauthorizedException.class, + () -> authService.signin(successSigninDto)); + assertEquals(unauthorizedException.getMessage(), INVALID_PASSWORD.getMessage()); + } + + @Test + void ๋กœ๊ทธ์ธ_์„ฑ๊ณต() { + // given + ReflectionTestUtils.setField(user, "deletedAt", null); + + String accessToken = "accessToken"; + String refreshToken = "refreshToken"; + + given(userService.findUserByEmailOrElseThrow(any(String.class))).willReturn(user); + given(passwordEncoder.matches(successSigninDto.getPassword(), user.getPassword())).willReturn(true); + given(tokenService.createAccessToken(any(User.class))).willReturn(accessToken); + given(tokenService.createRefreshToken(any(User.class))).willReturn(refreshToken); + + // when + AuthTokensResponseDto result = authService.signin(successSigninDto); + + // then + assertEquals(accessToken, result.getAccessToken()); + assertEquals(refreshToken, result.getRefreshToken()); + } + + @Test + void ํ† ํฐ_์žฌ๋ฐœ๊ธ‰_์„ฑ๊ณต() { + // given + String refreshToken = "refreshToken"; + + String reissuedAccessToken = "reissued-accessToken"; + String reissuedRefreshToken = "reissued-refreshToken"; + + given(tokenService.reissueToken(refreshToken)).willReturn(user); + given(tokenService.createAccessToken(any(User.class))).willReturn(reissuedAccessToken); + given(tokenService.createRefreshToken(any(User.class))).willReturn(reissuedRefreshToken); + + // when + AuthTokensResponseDto result = authService.reissueAccessToken(refreshToken); + + // then + assertEquals(reissuedAccessToken, result.getAccessToken()); + assertEquals(reissuedRefreshToken, result.getRefreshToken()); + } +} diff --git a/src/test/java/com/example/eightyage/domain/auth/service/TokenServiceTest.java b/src/test/java/com/example/eightyage/domain/auth/service/TokenServiceTest.java new file mode 100644 index 0000000..455f169 --- /dev/null +++ b/src/test/java/com/example/eightyage/domain/auth/service/TokenServiceTest.java @@ -0,0 +1,132 @@ +package com.example.eightyage.domain.auth.service; + +import com.example.eightyage.domain.auth.entity.RefreshToken; +import com.example.eightyage.domain.auth.repository.RefreshTokenRepository; +import com.example.eightyage.domain.user.entity.User; +import com.example.eightyage.domain.user.userrole.UserRole; +import com.example.eightyage.domain.user.service.UserService; +import com.example.eightyage.global.exception.NotFoundException; +import com.example.eightyage.global.exception.UnauthorizedException; +import com.example.eightyage.global.util.JwtUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static com.example.eightyage.domain.auth.tokenstate.TokenState.INVALIDATED; +import static com.example.eightyage.global.exception.ErrorMessage.EXPIRED_REFRESH_TOKEN; +import static com.example.eightyage.global.exception.ErrorMessage.REFRESH_TOKEN_NOT_FOUND; +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class TokenServiceTest { + + @Mock + private RefreshTokenRepository refreshTokenRepository; + @Mock + private UserService userService; + @Mock + private JwtUtil jwtUtil; + + @InjectMocks + private TokenService tokenService; + + private User user; + + @BeforeEach + public void setUp() { + user = User.builder() + .email("email@email.com") + .nickname("nickname") + .userRole(UserRole.ROLE_USER) + .build(); + + } + + /* createAccessToken */ + @Test + void ํ† ํฐ๋ฐœ๊ธ‰_AccessToken_๋ฐœ๊ธ‰_์„ฑ๊ณต() { + // given + String accessToken = "accessToken"; + + given(jwtUtil.createAccessToken(user.getId(), user.getEmail(), user.getNickname(), user.getUserRole())).willReturn(accessToken); + + // when + String result = tokenService.createAccessToken(user); + + // then + assertEquals(accessToken, result); + } + + /* createRefreshToken */ + @Test + void ํ† ํฐ๋ฐœ๊ธ‰_RefreshToken_๋ฐœ๊ธ‰_์„ฑ๊ณต() { + // given + RefreshToken mockRefreshToken = new RefreshToken(user.getId()); + + given(refreshTokenRepository.save(any(RefreshToken.class))).willReturn(mockRefreshToken); + + // when + String createdRefreshToken = tokenService.createRefreshToken(user); + + // then + verify(refreshTokenRepository, times(1)).save(any(RefreshToken.class)); + assertEquals(mockRefreshToken.getToken(), createdRefreshToken); + } + + /* reissueToken */ + @Test + void ํ† ํฐ์œ ํšจ์„ฑ๊ฒ€์‚ฌ_๋น„ํ™œ์„ฑ_์ƒํƒœ์ผ๋•Œ_์‹คํŒจ() { + // given + String refreshToken = "refresh-token"; + + RefreshToken mockRefreshToken = mock(RefreshToken.class); + + given(refreshTokenRepository.findByToken(any(String.class))).willReturn(Optional.of(mockRefreshToken)); + given(mockRefreshToken.getTokenState()).willReturn(INVALIDATED); + + // when & then + UnauthorizedException unauthorizedException = assertThrows(UnauthorizedException.class, + () -> tokenService.reissueToken(refreshToken)); + assertEquals(unauthorizedException.getMessage(), EXPIRED_REFRESH_TOKEN.getMessage()); + } + + @Test + void ํ† ํฐ๊ฒ€์ƒ‰_ํ† ํฐ์ด_์—†์„_์‹œ_์‹คํŒจ() { + //given + String refreshToken = "refresh-token"; + + given(refreshTokenRepository.findByToken(any(String.class))).willReturn(Optional.empty()); + + // when & then + NotFoundException notFoundException = assertThrows(NotFoundException.class, + () -> tokenService.reissueToken(refreshToken)); + assertEquals(notFoundException.getMessage(), REFRESH_TOKEN_NOT_FOUND.getMessage()); + } + + @Test + void ํ† ํฐ์œ ํšจ์„ฑ๊ฒ€์‚ฌ_์„ฑ๊ณต() { + // given + String refreshToken = "refresh-token"; + + RefreshToken mockRefreshToken = mock(RefreshToken.class); + + given(refreshTokenRepository.findByToken(any(String.class))).willReturn(Optional.of(mockRefreshToken)); + given(userService.findUserByIdOrElseThrow(anyLong())).willReturn(user); + + // when + User result = tokenService.reissueToken(refreshToken); + + // then + assertNotNull(result); + verify(mockRefreshToken, times(1)).updateTokenStatus(INVALIDATED); + } +} diff --git a/src/test/java/com/example/eightyage/domain/product/service/ProductImageServiceTest.java b/src/test/java/com/example/eightyage/domain/product/service/ProductImageServiceTest.java new file mode 100644 index 0000000..b3898b8 --- /dev/null +++ b/src/test/java/com/example/eightyage/domain/product/service/ProductImageServiceTest.java @@ -0,0 +1,102 @@ +package com.example.eightyage.domain.product.service; + +import com.example.eightyage.domain.product.category.Category; +import com.example.eightyage.domain.product.entity.Product; +import com.example.eightyage.domain.product.entity.ProductImage; +import com.example.eightyage.domain.product.repository.ProductImageRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectResponse; + +import java.util.Optional; +import java.util.function.Consumer; + + +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 ProductImageServiceTest { + + @Mock + S3Client s3Client; + + @Mock + ProductImageRepository productImageRepository; + + @Mock + ProductService productService; + + @InjectMocks + ProductImageService productImageService; + + @Mock + ProductImage productImage; + + private MockMultipartFile mockFile; + + private Product mockProduct; + + @BeforeEach + void setUp(){ + mockFile = new MockMultipartFile( + "file", + "test.jpg", + "image/jpeg", + "test image content".getBytes() + ); + + mockProduct = Product.builder() + .name("Test Product") + .category(Category.SKINCARE) + .content("This is test product.") + .price(10000) + .build(); + + ReflectionTestUtils.setField(mockProduct, "id", 1L); + } + + @Test + void ์ด๋ฏธ์ง€_์—…๋กœ๋“œ_์„ฑ๊ณต() { + // given + Long productId = 1L; + given(productService.findProductByIdOrElseThrow(productId)).willReturn(mockProduct); + + ProductImage mockProductImage = mock(ProductImage.class); + given(productImageRepository.save(any(ProductImage.class))).willReturn(mockProductImage); + + when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .thenReturn(PutObjectResponse.builder().build()); + + // when + String imageUrl = productImageService.uploadImage(productId, mockFile); + + // then + assertNotNull(imageUrl); + assertTrue(imageUrl.startsWith("https://my-gom-bucket.s3.ap-northeast-2.amazonaws.com/")); + } + + + @Test + void ์ด๋ฏธ์ง€_์‚ญ์ œ_์„ฑ๊ณต(){ + // given + Long imageId = 1L; + given(productImageRepository.findById(imageId)).willReturn(Optional.of(productImage)); + + // when + productImageService.deleteImage(imageId); + + // then + verify(productImageRepository, times(1)).delete(productImage); + } +} \ No newline at end of file diff --git a/src/test/java/com/example/eightyage/domain/product/service/ProductServiceTest.java b/src/test/java/com/example/eightyage/domain/product/service/ProductServiceTest.java new file mode 100644 index 0000000..aa91cb0 --- /dev/null +++ b/src/test/java/com/example/eightyage/domain/product/service/ProductServiceTest.java @@ -0,0 +1,130 @@ +package com.example.eightyage.domain.product.service; + +import com.example.eightyage.domain.product.dto.request.ProductSaveRequestDto; +import com.example.eightyage.domain.product.dto.request.ProductUpdateRequestDto; +import com.example.eightyage.domain.product.dto.response.ProductGetResponseDto; +import com.example.eightyage.domain.product.dto.response.ProductSaveResponseDto; +import com.example.eightyage.domain.product.dto.response.ProductUpdateResponseDto; +import com.example.eightyage.domain.product.category.Category; +import com.example.eightyage.domain.product.entity.Product; +import com.example.eightyage.domain.product.repository.ProductImageRepository; +import com.example.eightyage.domain.product.salestate.SaleState; +import com.example.eightyage.domain.product.repository.ProductRepository; +import com.example.eightyage.domain.review.entity.Review; +import com.example.eightyage.domain.review.repository.ReviewRepository; +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.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +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 ProductServiceTest { + + @Mock + ProductRepository productRepository; + + @Mock + ProductImageRepository productImageRepository; + + @Mock + ReviewRepository reviewRepository; + + @InjectMocks + ProductService productService; + + @Mock + private Product product; + + @Mock + private Review review1; + + @Mock + private Review review2; + + @Test + void ์ œํ’ˆ_์ƒ์„ฑ_์„ฑ๊ณต(){ + // given + given(productRepository.save(any())).willReturn(product); + + ProductSaveRequestDto requestDto = new ProductSaveRequestDto("8์ž ์ฃผ๋ฆ„ ์Šคํ‚จ", Category.SKINCARE, "8์ž ์ฃผ๋ฆ„์„ 1์ž๋กœ ํŽด์ค๋‹ˆ๋‹ค.", 20000); + + // when + ProductSaveResponseDto savedProduct = productService.saveProduct(requestDto); + + // then + assertThat(savedProduct.getProductName()).isEqualTo(product.getName()); + } + + @Test + void ์ œํ’ˆ_์ˆ˜์ •_์„ฑ๊ณต(){ + // given + Long productId = 1L; + List reviewList = new ArrayList<>(); + reviewList.add(review1); + reviewList.add(review2); + + Product product = new Product(1L, "8์ž ์ฃผ๋ฆ„ ์Šคํ‚จ", Category.SKINCARE, "8์ž ์ฃผ๋ฆ„์„ 1์ž๋กœ ํŽด์ฃผ๋Š” ํผํŽ™ํŠธ ์Šคํ‚จ", 20000, SaleState.FOR_SALE, reviewList, null); + + given(productRepository.findById(any(Long.class))).willReturn(Optional.of(product)); + + ProductUpdateRequestDto requestDto = new ProductUpdateRequestDto("8์ž ์ฃผ๋ฆ„ ํ–ฅ์ˆ˜", Category.FRAGRANCE, "8์ž ์ฃผ๋ฆ„์˜ ์€์€ํ•œ ํ–ฅ๊ธฐ", SaleState.FOR_SALE, 50000); + + // when + ProductUpdateResponseDto responseDto = productService.updateProduct(productId, requestDto); + + // then + assertThat(responseDto.getProductName()).isEqualTo(requestDto.getProductName()); + } + + @Test + void ์ œํ’ˆ_๋‹จ๊ฑด_์กฐํšŒ_์„ฑ๊ณต(){ + // given + Long productId = 1L; + List productImageList = new ArrayList<>(); + + productImageList.add("image1.png"); + productImageList.add("image2.png"); + + given(productRepository.findById(any(Long.class))).willReturn(Optional.of(product)); + given(productImageRepository.findProductImageByProductId(any(Long.class))).willReturn(productImageList); + + // when + ProductGetResponseDto responseDto = productService.getProductById(productId); + + // then + assertThat(responseDto.getProductName()).isEqualTo(product.getName()); + } + + @Test + void ์ œํ’ˆ_์‚ญ์ œ_์„ฑ๊ณต(){ + // given + Long productId = 1L; + + List reviewList = new ArrayList<>(); + reviewList.add(review1); + reviewList.add(review2); + + given(productRepository.findById(any(Long.class))).willReturn(Optional.of(product)); + given(reviewRepository.findReviewsByProductId(any(Long.class))).willReturn(reviewList); + + // when + productService.deleteProduct(productId); + + // then + verify(reviewRepository, times(1)).deleteAll(reviewList); + verify(product, times(1)).deleteProduct(); + } +} \ No newline at end of file diff --git a/src/test/java/com/example/eightyage/domain/review/service/ReviewServiceTest.java b/src/test/java/com/example/eightyage/domain/review/service/ReviewServiceTest.java new file mode 100644 index 0000000..acfaa82 --- /dev/null +++ b/src/test/java/com/example/eightyage/domain/review/service/ReviewServiceTest.java @@ -0,0 +1,187 @@ +package com.example.eightyage.domain.review.service; + +import com.example.eightyage.domain.product.entity.Product; +import com.example.eightyage.domain.product.service.ProductService; +import com.example.eightyage.domain.review.dto.request.ReviewSaveRequestDto; +import com.example.eightyage.domain.review.dto.request.ReviewUpdateRequestDto; +import com.example.eightyage.domain.review.dto.response.ReviewSaveResponseDto; +import com.example.eightyage.domain.review.dto.response.ReviewUpdateResponseDto; +import com.example.eightyage.domain.review.dto.response.ReviewsGetResponseDto; +import com.example.eightyage.domain.review.entity.Review; +import com.example.eightyage.domain.review.repository.ReviewRepository; +import com.example.eightyage.domain.user.entity.User; +import com.example.eightyage.domain.user.service.UserService; +import com.example.eightyage.domain.user.userrole.UserRole; +import com.example.eightyage.global.exception.UnauthorizedException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; + +import java.util.ArrayList; +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 ReviewServiceTest { + + @Mock + ReviewRepository reviewRepository; + + @Mock + UserService userService; + + @Mock + ProductService productService; + + @InjectMocks + ReviewService reviewService; + + @Mock + User user; + + @Mock + Product product; + + @Mock + Review review; + + @Test + void ๋ฆฌ๋ทฐ_์ƒ์„ฑ_์„ฑ๊ณต(){ + // given + Long userId = 1L; + Long productId = 1L; + Long reviewId = 1L; + + Review review = new Review(reviewId, user, product, 5.0, "8์ž ์ฃผ๋ฆ„์„ ๋‹ค๋ฆฌ๋ฏธ์ฒ˜๋Ÿผ ํŽด์ค˜์š” ์งฑ์งฑ"); + given(reviewRepository.save(any())).willReturn(review); + + ReviewSaveRequestDto requestDto = new ReviewSaveRequestDto(5.0, "8์ž ์ฃผ๋ฆ„์„ ๋‹ค๋ฆฌ๋ฏธ์ฒ˜๋Ÿผ ํŽด์ค˜์š” ์งฑ์งฑ"); + + // when + ReviewSaveResponseDto responseDto = reviewService.saveReview(userId, productId, requestDto); + + // then + assertEquals(requestDto.getContent(), responseDto.getContent()); + } + + @Test + void ๋ฆฌ๋ทฐ_์ˆ˜์ •_์ž‘์„ฑํ•œ_๋ณธ์ธ์ด_์•„๋‹_๊ฒฝ์šฐ_์‹คํŒจ(){ + // given + Long userId = 2L; + Long reviewId = 1L; + ReviewUpdateRequestDto requestDto = new ReviewUpdateRequestDto(1.0, "์“ฐ๋‹ค๋ณด๋‹ˆ 8์ž ์ฃผ๋ฆ„์ด ๊นŠ์–ด์กŒ์–ด์š”. ๋Œ€์‹ค๋ง"); + + User user1 = new User(1L, "ijieun@gmail.com", "์ด์ง€์€B", "password123", UserRole.ROLE_USER); + User user2 = new User(userId, "ijieun@gmail.com", "์ด์ง€์€B", "password123", UserRole.ROLE_USER); + Review review = new Review(reviewId, user1, product, 5.0, "8์ž ์ฃผ๋ฆ„์„ ํŽด์ค˜์š”"); + + given(userService.findUserByIdOrElseThrow(any())).willReturn(user2); + given(reviewRepository.findById(any())).willReturn(Optional.of(review)); + + // when + UnauthorizedException exception = assertThrows(UnauthorizedException.class, () -> { + reviewService.updateReview(userId, reviewId, requestDto); + }); + + // then + assertEquals("๋ฆฌ๋ทฐ๋ฅผ ์ˆ˜์ •ํ•  ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค.", exception.getMessage()); + } + + @Test + void ๋ฆฌ๋ทฐ_์ˆ˜์ •_์„ฑ๊ณต(){ + // given + Long userId = 1L; + Long reviewId = 1L; + ReviewUpdateRequestDto requestDto = new ReviewUpdateRequestDto(1.0, "์“ฐ๋‹ค๋ณด๋‹ˆ 8์ž ์ฃผ๋ฆ„์ด ๊นŠ์–ด์กŒ์–ด์š”. ๋Œ€์‹ค๋ง"); + + User user = new User(userId, "ijieun@gmail.com", "์ด์ง€์€B", "password123", UserRole.ROLE_USER); + Review review = new Review(reviewId, user, product, 5.0, "8์ž ์ฃผ๋ฆ„์„ ํŽด์ค˜์š”"); + + given(userService.findUserByIdOrElseThrow(any())).willReturn(user); + given(reviewRepository.findById(any())).willReturn(Optional.of(review)); + + // when + ReviewUpdateResponseDto responseDto = reviewService.updateReview(userId, reviewId, requestDto); + + // then + assertEquals(requestDto.getContent(), responseDto.getContent()); + } + + @Test + void ๋ฆฌ๋ทฐ_๋‹ค๊ฑด_์กฐํšŒ_์„ฑ๊ณต(){ + // given + Long productId = 1L; + PageRequest pageRequest = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "score")); + + Review review1 = new Review(1L, user, product, 5.0, "8์ž ์ฃผ๋ฆ„์„ ํŽด์ค˜์š”"); + Review review2 = new Review(1L, user, product, 5.0, "8์ž ์ฃผ๋ฆ„์„ ํŽด์ค˜์š”"); + + List reviewList = new ArrayList<>(); + reviewList.add(review1); + reviewList.add(review2); + + Page reviewPage = new PageImpl<>(reviewList, pageRequest, reviewList.size()); + + when(reviewRepository.findByProductIdAndProductDeletedAtIsNull(any(Long.class), eq(pageRequest))).thenReturn(reviewPage); + + // when + Page result = reviewService.getReviews(productId, pageRequest); + + // then + assertNotNull(result); + assertEquals(2, result.getContent().size()); + verify(reviewRepository, times(1)).findByProductIdAndProductDeletedAtIsNull(any(Long.class), eq(pageRequest)); + } + + @Test + void ๋ฆฌ๋ทฐ_์‚ญ์ œ_์ž‘์„ฑํ•œ_๋ณธ์ธ์ด_์•„๋‹_๊ฒฝ์šฐ_์‹คํŒจ(){ + // given + Long userId = 2L; + Long reviewId = 1L; + + User user1 = new User(1L, "ijieun@gmail.com", "์ด์ง€์€B", "password123", UserRole.ROLE_USER); + User user2 = new User(userId, "ijieun@gmail.com", "์ด์ง€์€B", "password123", UserRole.ROLE_USER); + Review review = new Review(reviewId, user1, product, 5.0, "8์ž ์ฃผ๋ฆ„์„ ํŽด์ค˜์š”"); + + given(userService.findUserByIdOrElseThrow(any())).willReturn(user2); + given(reviewRepository.findById(any())).willReturn(Optional.of(review)); + + // when + UnauthorizedException exception = assertThrows(UnauthorizedException.class, () -> { + reviewService.deleteReview(userId, reviewId); + }); + + // then + assertEquals("๋ฆฌ๋ทฐ๋ฅผ ์‚ญ์ œํ•  ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค.", exception.getMessage()); + } + + @Test + void ๋ฆฌ๋ทฐ_์‚ญ์ œ_์„ฑ๊ณต(){ + // given + Long userId = 1L; + Long reviewId = 1L; + + User user = new User(userId, "ijieun@gmail.com", "์ด์ง€์€B", "password123", UserRole.ROLE_USER); + + given(userService.findUserByIdOrElseThrow(any())).willReturn(user); + given(reviewRepository.findById(any())).willReturn(Optional.of(review)); + given(review.getUser()).willReturn(user); + + // when + reviewService.deleteReview(userId, reviewId); + + // then + verify(reviewRepository, times(1)).delete(review); + } +} \ No newline at end of file diff --git a/src/test/java/com/example/eightyage/domain/user/service/UserServiceTest.java b/src/test/java/com/example/eightyage/domain/user/service/UserServiceTest.java new file mode 100644 index 0000000..e2fd5f7 --- /dev/null +++ b/src/test/java/com/example/eightyage/domain/user/service/UserServiceTest.java @@ -0,0 +1,216 @@ +package com.example.eightyage.domain.user.service; + +import com.example.eightyage.domain.user.dto.request.UserDeleteRequestDto; +import com.example.eightyage.domain.user.entity.User; +import com.example.eightyage.domain.user.userrole.UserRole; +import com.example.eightyage.domain.user.repository.UserRepository; +import com.example.eightyage.global.dto.AuthUser; +import com.example.eightyage.global.exception.BadRequestException; +import com.example.eightyage.global.exception.NotFoundException; +import com.example.eightyage.global.exception.UnauthorizedException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static com.example.eightyage.global.exception.ErrorMessage.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +public class UserServiceTest { + + @Mock + private UserRepository userRepository; + @Mock + private PasswordEncoder passwordEncoder; + + @InjectMocks + private UserService userService; + + private User user; + private AuthUser authUser; + private UserDeleteRequestDto successDeleteDto; + private UserDeleteRequestDto wrongPasswordDeleteDto; + + @BeforeEach + public void setUp() { + user = User.builder() + .nickname("nickname") + .userRole(UserRole.ROLE_USER) + .build(); + + authUser = AuthUser.builder() + .userId(1L) + .email("email@email.com") + .nickname("nickname") + .role(UserRole.ROLE_USER) + .build(); + + successDeleteDto = UserDeleteRequestDto.builder() + .password("correct-password") + .build(); + wrongPasswordDeleteDto = UserDeleteRequestDto.builder() + .password("wrong-password") + .build(); + } + + /* findUserByIdOrElseThrow */ + @Test + void findById์กฐํšŒ_userId๊ฐ€_์—†์„_๊ฒฝ์šฐ_์‹คํŒจ() { + // given + Long userId = 1L; + + given(userRepository.findById(anyLong())).willReturn(Optional.empty()); + + // when & then + NotFoundException notFoundException = assertThrows(NotFoundException.class, + () -> userService.findUserByIdOrElseThrow(userId)); + assertEquals(notFoundException.getMessage(), USER_ID_NOT_FOUND.getMessage()); + } + + @Test + void findById์กฐํšŒ_์„ฑ๊ณต() { + // given + Long userId = 1L; + ReflectionTestUtils.setField(user, "id", userId); + + given(userRepository.findById(anyLong())).willReturn(Optional.of(user)); + + // when + User resultUser = userService.findUserByIdOrElseThrow(userId); + + // then + assertNotNull(resultUser); + assertEquals(user.getId(), resultUser.getId()); + assertEquals(user.getNickname(), resultUser.getNickname()); + assertEquals(user.getUserRole(), resultUser.getUserRole()); + } + + /* findUserByEmailOrElseThrow */ + @Test + void findByEmail์กฐํšŒ_email์ด_์—†์„_๊ฒฝ์šฐ_์‹คํŒจ() { + // given + String email = "email@email.com"; + + given(userRepository.findByEmail(any(String.class))).willReturn(Optional.empty()); + + // when & then + UnauthorizedException unauthorizedException = assertThrows(UnauthorizedException.class, + () -> userService.findUserByEmailOrElseThrow(email)); + assertEquals(unauthorizedException.getMessage(), USER_EMAIL_NOT_FOUND.getMessage()); + } + + @Test + void findByEmail์กฐํšŒ_์„ฑ๊ณต() { + // given + String email = "email@email.com"; + ReflectionTestUtils.setField(user, "email", email); + + given(userRepository.findByEmail(any(String.class))).willReturn(Optional.of(user)); + + // when + User resultUser = userService.findUserByEmailOrElseThrow(email); + + // then + assertNotNull(resultUser); + assertEquals(user.getEmail(), resultUser.getEmail()); + assertEquals(user.getNickname(), resultUser.getNickname()); + assertEquals(user.getUserRole(), resultUser.getUserRole()); + } + + /* saveUser */ + @Test + void ํšŒ์›์ €์žฅ_์ค‘๋ณต๋œ_์ด๋ฉ”์ผ์ด_์žˆ์„_๊ฒฝ์šฐ_์‹คํŒจ() { + // given + String email = "email@email.com"; + String nickname = "nickname"; + String password = "password1234"; + String userRole = "USER_ROLE"; + + given(userRepository.existsByEmail(any(String.class))).willReturn(true); + + // when & then + BadRequestException badRequestException = assertThrows(BadRequestException.class, + () -> userService.saveUser(email, nickname, password, userRole)); + assertEquals(badRequestException.getMessage(), DUPLICATE_EMAIL.getMessage()); + } + + @Test + void ํšŒ์›์ €์žฅ_์„ฑ๊ณต() { + // given + String email = "email@email.com"; + String nickname = "nickname"; + String password = "password1234"; + String userRole = "ROLE_USER"; + ReflectionTestUtils.setField(user, "email", "email@email.com"); + ReflectionTestUtils.setField(user, "password", "password1234"); + + + String encodedPassword = "encoded-password1234"; + + given(userRepository.existsByEmail(any(String.class))).willReturn(false); + given(passwordEncoder.encode(any(String.class))).willReturn(encodedPassword); + given(userRepository.save(any(User.class))).willReturn(user); + + // when + User resultUser = userService.saveUser(email, nickname, password, userRole); + + // then + assertNotNull(resultUser); + assertEquals(email, resultUser.getEmail()); + assertEquals(nickname, resultUser.getNickname()); + assertEquals(password, resultUser.getPassword()); + assertEquals(UserRole.of(userRole), resultUser.getUserRole()); + + } + + /* deleteUser */ + @Test + void ํšŒ์›ํƒˆํ‡ด_ํšŒ์›์ด_์กด์žฌํ•˜์ง€_์•Š์œผ๋ฉด_์‹คํŒจ() { + // given + given(userRepository.findById(anyLong())).willReturn(Optional.empty()); + + // when & then + NotFoundException notFoundException = assertThrows(NotFoundException.class, + () -> userService.deleteUser(authUser, successDeleteDto)); + assertEquals(notFoundException.getMessage(), USER_ID_NOT_FOUND.getMessage()); + } + + @Test + void ํšŒ์›ํƒˆํ‡ด_๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€_์ผ์น˜ํ•˜์ง€_์•Š์œผ๋ฉด_์‹คํŒจ() { + // given + ReflectionTestUtils.setField(user, "password", "correct-password"); + + given(userRepository.findById(anyLong())).willReturn(Optional.of(user)); + given(passwordEncoder.matches(any(String.class), any(String.class))).willReturn(false); + + // when & then + UnauthorizedException unauthorizedException = assertThrows(UnauthorizedException.class, + () -> userService.deleteUser(authUser, wrongPasswordDeleteDto)); + assertEquals(unauthorizedException.getMessage(), INVALID_PASSWORD.getMessage()); + } + + @Test + void ํšŒ์›ํƒˆํ‡ด_์„ฑ๊ณต() { + // given + ReflectionTestUtils.setField(user, "password", "wrong-password"); + + given(userRepository.findById(anyLong())).willReturn(Optional.of(user)); + given(passwordEncoder.matches(any(String.class), any(String.class))).willReturn(true); + + // when + userService.deleteUser(authUser, successDeleteDto); + + // then + assertNotNull(user.getDeletedAt()); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 0000000..bb86911 --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,33 @@ +spring: + datasource: + url: ${DB_URL} + username: ${DB_USER} + password: ${DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + hibernate: + ddl-auto: update + properties: + hibernate: + show_sql: true + format_sql: true + use_sql_comments: true + dialect: org.hibernate.dialect.MySQLDialect + cloud: + aws: + credentials: + access-key: ${AWS_ACCESS_KEY} + secret-key: ${AWS_SECRET_KEY} + region: + static: ap-northeast-2 + s3: + bucket: my-gom-bucket + +aws: + credentials: + access-key: ${AWS_ACCESS_KEY} + secret-key: ${AWS_SECRET_KEY} + region: ap-northeast-2 + s3: + bucket: my-gom-bucket \ No newline at end of file