diff --git a/.github/workflows/main_merge.yml b/.github/workflows/main_merge.yml index a33b13a..f45aeef 100644 --- a/.github/workflows/main_merge.yml +++ b/.github/workflows/main_merge.yml @@ -42,7 +42,7 @@ jobs: host: ${{ secrets.MAIN_HOST }} key: ${{ secrets.MAIN_SSH_KEY }} source: "build/libs/*.jar" - target: "/home/ubuntu/docker/java/test/jar" + target: "/home/ready/docker/readyvery/jar" - name: SSH and deploy uses: appleboy/ssh-action@v1.0.0 with: diff --git a/.github/workflows/main_pr.yml b/.github/workflows/main_pr.yml index 1c3ebac..2aa4559 100644 --- a/.github/workflows/main_pr.yml +++ b/.github/workflows/main_pr.yml @@ -47,7 +47,7 @@ jobs: if: needs.build.result == 'failure' uses: actions/github-script@v6 with: - github-token: ${{ github.token }} + github-token: ${{ secrets.GITHUB_TOKEN }} script: | github.rest.issues.createComment({ issue_number: context.issue.number, @@ -67,7 +67,7 @@ jobs: if: needs.build.result == 'success' uses: actions/github-script@v6 with: - github-token: ${{ github.token }} + github-token: ${{ secrets.GITHUB_TOKEN }} script: | github.rest.pulls.createReview({ owner: context.repo.owner, diff --git a/.github/workflows/test_pr.yml b/.github/workflows/test_pr.yml index 5e9216b..1caaa30 100644 --- a/.github/workflows/test_pr.yml +++ b/.github/workflows/test_pr.yml @@ -7,6 +7,20 @@ on: jobs: build: runs-on: ubuntu-latest + services: + # Label used to access the service container + redis: + # Docker Hub image + image: redis + # Set health checks to wait until redis has started + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + # Maps port 6379 on service container to the host + - 6379:6379 steps: - name: Checkout source code. # Repo checkout uses: actions/checkout@v3 @@ -39,7 +53,7 @@ jobs: if: needs.build.result == 'failure' uses: actions/github-script@v6 with: - github-token: ${{ github.token }} + github-token: ${{ secrets.GITHUB_TOKEN }} script: | github.rest.issues.createComment({ issue_number: context.issue.number, @@ -59,7 +73,7 @@ jobs: if: needs.build.result == 'success' uses: actions/github-script@v6 with: - github-token: ${{ github.token }} + github-token: ${{ secrets.GITHUB_TOKEN }} script: | github.rest.pulls.createReview({ owner: context.repo.owner, diff --git a/.gitignore b/.gitignore index c2065bc..7517607 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,5 @@ out/ ### VS Code ### .vscode/ + +/src/main/resources/application.properties diff --git a/build.gradle b/build.gradle index 67f2524..ba391eb 100644 --- a/build.gradle +++ b/build.gradle @@ -42,6 +42,24 @@ dependencies { runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + // redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' + + // Bouncy Castle Provider + implementation 'org.bouncycastle:bcprov-jdk15on:1.70' + + // Bouncy Castle PKIX/CMS/EAC/PKCS/OCSP/TSP/OPENSSL + implementation 'org.bouncycastle:bcpkix-jdk15on:1.70' + + // Apache Commons IO + implementation 'commons-io:commons-io:2.11.0' + + //SOLAPI + implementation 'net.nurigo:sdk:4.2.7' + // //Querydsl 추가 // implementation 'com.querydsl:querydsl-core:5.0.0' // implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' diff --git a/src/main/java/com/readyvery/readyverydemo/config/JwtConfig.java b/src/main/java/com/readyvery/readyverydemo/config/JwtConfig.java new file mode 100644 index 0000000..955afb0 --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/config/JwtConfig.java @@ -0,0 +1,58 @@ +package com.readyvery.readyverydemo.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +import com.auth0.jwt.algorithms.Algorithm; + +import lombok.Getter; + +@Configuration +@Getter +public class JwtConfig { + + private final String secretKey; + private final Long accessTokenExpirationPeriod; + private final Long refreshTokenExpirationPeriod; + private final String accessTokenName; + private final String refreshTokenName; + private final String userFrontendUrl; + private final String guestFrontendUrl; + private final String cookieDomain; + private final Algorithm algorithm; + + public static final String ACCESS_TOKEN_SUBJECT = "AccessToken"; + public static final String REFRESH_TOKEN_SUBJECT = "RefreshToken"; + public static final String EMAIL_CLAIM = "email"; + public static final String USER_NUMBER = "userNumber"; + public static final String BEARER = "Bearer "; + public static final String AUTHORIZATION = "Authorization"; + + public JwtConfig( + @Value("${jwt.secretKey}") String secretKey, + @Value("${jwt.access.expiration}") Long accessTokenExpirationPeriod, + @Value("${jwt.refresh.expiration}") Long refreshTokenExpirationPeriod, + @Value("${jwt.access.cookie}") String accessTokenName, + @Value("${jwt.refresh.cookie}") String refreshTokenName, + @Value("${jwt.redirect-uri-user}") String userFrontendUrl, + @Value("${jwt.redirect-uri-guest}") String guestFrontendUrl, + @Value("${jwt.cookie.domain}") String cookieDomain + ) { + this.secretKey = secretKey; + this.accessTokenExpirationPeriod = accessTokenExpirationPeriod; + this.refreshTokenExpirationPeriod = refreshTokenExpirationPeriod; + this.accessTokenName = accessTokenName; + this.refreshTokenName = refreshTokenName; + this.userFrontendUrl = userFrontendUrl; + this.guestFrontendUrl = guestFrontendUrl; + this.cookieDomain = cookieDomain; + this.algorithm = initializeAlgorithm(secretKey); + } + + private Algorithm initializeAlgorithm(String secretKey) { + if (secretKey == null || secretKey.trim().isEmpty()) { + throw new IllegalArgumentException("Secret key must not be null or empty"); + } + return Algorithm.HMAC512(secretKey); + } +} diff --git a/src/main/java/com/readyvery/readyverydemo/config/OauthConfig.java b/src/main/java/com/readyvery/readyverydemo/config/OauthConfig.java new file mode 100644 index 0000000..87cb7b5 --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/config/OauthConfig.java @@ -0,0 +1,29 @@ +package com.readyvery.readyverydemo.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Configuration +@Getter +public class OauthConfig { + + @Value("${app.apple.url}") + private String appleUrl; + + @Value("${app.apple.private-key}") + private String privateKeyString; + @Value("${app.apple.client-id}") + private String appleClientId; + + @Value("${app.apple.team-id}") + private String appleTeamId; + + @Value("${app.apple.key-id}") + private String appleKeyId; + public static final String KAKAO_NAME = "kakao"; + public static final String APPLE_NAME = "apple"; +} diff --git a/src/main/java/com/readyvery/readyverydemo/config/RedisConfig.java b/src/main/java/com/readyvery/readyverydemo/config/RedisConfig.java new file mode 100644 index 0000000..74b75c1 --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/config/RedisConfig.java @@ -0,0 +1,29 @@ +package com.readyvery.readyverydemo.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + + LettuceConnectionFactory lettuceConnectionFactory = new LettuceConnectionFactory(host, port); + log.info("RedisConfig.redisConnectionFactory() called" + lettuceConnectionFactory); + return lettuceConnectionFactory; + } + +} diff --git a/src/main/java/com/readyvery/readyverydemo/config/SolApiConfig.java b/src/main/java/com/readyvery/readyverydemo/config/SolApiConfig.java new file mode 100644 index 0000000..3aa5fd4 --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/config/SolApiConfig.java @@ -0,0 +1,44 @@ +package com.readyvery.readyverydemo.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import net.nurigo.sdk.NurigoApp; +import net.nurigo.sdk.message.service.DefaultMessageService; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +@Configuration +public class SolApiConfig { + public static final String SOLAPI_URL = "https://api.solapi.com"; + + @Value("${solapi.api_key}") + private String apiKey; + + @Value("${solapi.secret_key}") + private String apiSecret; + + @Value("${solapi.templete.cancel}") + private String templeteCancel; + + @Value("${solapi.templete.pickup}") + private String templetePickup; + + @Value("${solapi.templete.order}") + private String templeteOrder; + + @Value("${solapi.kakao.pfid}") + private String kakaoPfid; + + @Value("${solapi.phone_number}") + private String phoneNumber; + + @Bean + public DefaultMessageService defaultMessageService() { + return NurigoApp.INSTANCE.initialize(apiKey, apiSecret, SOLAPI_URL); + } +} diff --git a/src/main/java/com/readyvery/readyverydemo/security/config/SpringSecurityConfig.java b/src/main/java/com/readyvery/readyverydemo/config/SpringSecurityConfig.java similarity index 80% rename from src/main/java/com/readyvery/readyverydemo/security/config/SpringSecurityConfig.java rename to src/main/java/com/readyvery/readyverydemo/config/SpringSecurityConfig.java index 589f113..47aec7d 100644 --- a/src/main/java/com/readyvery/readyverydemo/security/config/SpringSecurityConfig.java +++ b/src/main/java/com/readyvery/readyverydemo/config/SpringSecurityConfig.java @@ -1,4 +1,4 @@ -package com.readyvery.readyverydemo.security.config; +package com.readyvery.readyverydemo.config; import java.util.Arrays; @@ -11,6 +11,10 @@ import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.factory.PasswordEncoderFactories; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequestEntityConverter; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.logout.LogoutFilter; import org.springframework.web.cors.CorsConfiguration; @@ -18,10 +22,12 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import com.fasterxml.jackson.databind.ObjectMapper; +import com.readyvery.readyverydemo.domain.repository.RefreshTokenRepository; import com.readyvery.readyverydemo.domain.repository.UserRepository; import com.readyvery.readyverydemo.security.exception.CustomAuthenticationEntryPoint; import com.readyvery.readyverydemo.security.jwt.filter.JwtAuthenticationProcessingFilter; import com.readyvery.readyverydemo.security.jwt.service.JwtService; +import com.readyvery.readyverydemo.security.oauth2.CustomRequestEntityConverter; import com.readyvery.readyverydemo.security.oauth2.handler.OAuth2LoginFailureHandler; import com.readyvery.readyverydemo.security.oauth2.handler.OAuth2LoginSuccessHandler; import com.readyvery.readyverydemo.security.oauth2.service.CustomOAuth2UserService; @@ -37,6 +43,8 @@ public class SpringSecurityConfig { private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; private final OAuth2LoginFailureHandler oAuth2LoginFailureHandler; private final CustomOAuth2UserService customOAuth2UserService; + private final RefreshTokenRepository refreshTokenRepository; + private final OauthConfig oauthConfig; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @@ -62,14 +70,17 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/api/v1/order/current", "/api/v1/auth", "/api/v1/event/**" + ).permitAll() // 위를 제외한 나머지는 모두 허용 .anyRequest().authenticated() // 해당 요청은 인증이 필요함 ) // [PART 3] //== 소셜 로그인 설정 ==// .oauth2Login(oauth2 -> oauth2 + .tokenEndpoint(token -> token.accessTokenResponseClient(accessTokenResponseClient())) // 토큰 엔드포인트 설정 .successHandler(oAuth2LoginSuccessHandler) // 동의하고 계속하기를 눌렀을 때 Handler 설정 .failureHandler(oAuth2LoginFailureHandler) // 소셜 로그인 실패 시 핸들러 설정 + .userInfoEndpoint(userInfo -> userInfo .userService(customOAuth2UserService))) @@ -121,7 +132,19 @@ public WebSecurityCustomizer webSecurityCustomizer() { @Bean public JwtAuthenticationProcessingFilter jwtAuthenticationProcessingFilter() { JwtAuthenticationProcessingFilter jwtAuthenticationFilter = new JwtAuthenticationProcessingFilter(jwtService, - userRepository); + userRepository, refreshTokenRepository); return jwtAuthenticationFilter; } + + @Bean + public OAuth2AccessTokenResponseClient accessTokenResponseClient() { + + DefaultAuthorizationCodeTokenResponseClient accessTokenResponseClient = + new DefaultAuthorizationCodeTokenResponseClient(); + accessTokenResponseClient.setRequestEntityConverter( + new CustomRequestEntityConverter(new OAuth2AuthorizationCodeGrantRequestEntityConverter(), + oauthConfig)); + + return accessTokenResponseClient; + } } diff --git a/src/main/java/com/readyvery/readyverydemo/config/UserApiConfig.java b/src/main/java/com/readyvery/readyverydemo/config/UserApiConfig.java new file mode 100644 index 0000000..efc3b02 --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/config/UserApiConfig.java @@ -0,0 +1,15 @@ +package com.readyvery.readyverydemo.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +import lombok.Getter; + +@Configuration +@Getter +public class UserApiConfig { + @Value("${jwt.refresh.cookie}") + private String refreshCookie; + @Value("${service.app.admin.key}") + private String serviceAppAdminKey; +} diff --git a/src/main/java/com/readyvery/readyverydemo/domain/Coupon.java b/src/main/java/com/readyvery/readyverydemo/domain/Coupon.java index 1ccd31b..d78abd0 100644 --- a/src/main/java/com/readyvery/readyverydemo/domain/Coupon.java +++ b/src/main/java/com/readyvery/readyverydemo/domain/Coupon.java @@ -13,6 +13,7 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import jakarta.persistence.Version; import lombok.AllArgsConstructor; import lombok.Builder; @@ -25,7 +26,9 @@ @Setter @Entity @Builder -@Table(name = "COUPON") +@Table(name = "COUPON", uniqueConstraints = { + @UniqueConstraint(columnNames = {"coupon_detail_idx", "user_idx"}) +}) @AllArgsConstructor @NoArgsConstructor @Slf4j @@ -36,9 +39,13 @@ public class Coupon extends BaseTimeEntity { @Column(name = "coupon_idx") private Long id; - // 사용 여부 - @Column(name = "used") - private boolean isUsed; + // 발급 갯수 + @Column(name = "issue_count") + private int issueCount; + + // 사용 갯수 + @Column(name = "use_count") + private Long useCount; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "coupon_detail_idx") diff --git a/src/main/java/com/readyvery/readyverydemo/domain/Order.java b/src/main/java/com/readyvery/readyverydemo/domain/Order.java index a88acbe..f732701 100644 --- a/src/main/java/com/readyvery/readyverydemo/domain/Order.java +++ b/src/main/java/com/readyvery/readyverydemo/domain/Order.java @@ -81,6 +81,10 @@ public class Order extends BaseTimeEntity { @Column private String message; + // 사용 포인트 + @Column + private Long point; + // 가게 아이템 연관 관계 // @OneToMany(mappedBy = "order") // @Builder.Default diff --git a/src/main/java/com/readyvery/readyverydemo/domain/Point.java b/src/main/java/com/readyvery/readyverydemo/domain/Point.java new file mode 100644 index 0000000..e5904a8 --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/domain/Point.java @@ -0,0 +1,47 @@ +package com.readyvery.readyverydemo.domain; + +import org.hibernate.annotations.ColumnDefault; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Table(name = "point") +public class Point extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "point_idx") + private Long id; + + // 발생 포인트 + @Column(name = "point") + private Long point; + + // 취소 여부 + @Column(name = "is_deleted") + @ColumnDefault("false") + private Boolean isDeleted; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_idx") + private UserInfo userInfo; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "order_idx") + private Order order; +} diff --git a/src/main/java/com/readyvery/readyverydemo/domain/RefreshToken.java b/src/main/java/com/readyvery/readyverydemo/domain/RefreshToken.java new file mode 100644 index 0000000..b46cdb7 --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/domain/RefreshToken.java @@ -0,0 +1,30 @@ +package com.readyvery.readyverydemo.domain; + +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.index.Indexed; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@AllArgsConstructor +@Getter +@Builder +@RedisHash(value = "refreshToken", timeToLive = 60 * 60 * 24 * 30) +public class RefreshToken { + + @Id + private String id; + + @Indexed + private String refreshToken; + + // @Indexed + // private String accessToken; + + public void update(String refreshToken) { + this.refreshToken = refreshToken; + + } +} diff --git a/src/main/java/com/readyvery/readyverydemo/domain/SocialType.java b/src/main/java/com/readyvery/readyverydemo/domain/SocialType.java index 4273104..d0219d2 100644 --- a/src/main/java/com/readyvery/readyverydemo/domain/SocialType.java +++ b/src/main/java/com/readyvery/readyverydemo/domain/SocialType.java @@ -1,6 +1,6 @@ package com.readyvery.readyverydemo.domain; public enum SocialType { - KAKAO, NAVER, GOOGLE + KAKAO, NAVER, GOOGLE, APPLE } diff --git a/src/main/java/com/readyvery/readyverydemo/domain/Store.java b/src/main/java/com/readyvery/readyverydemo/domain/Store.java index bc15655..f25fa03 100644 --- a/src/main/java/com/readyvery/readyverydemo/domain/Store.java +++ b/src/main/java/com/readyvery/readyverydemo/domain/Store.java @@ -3,6 +3,8 @@ import java.util.ArrayList; import java.util.List; +import org.hibernate.annotations.ColumnDefault; + import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -81,6 +83,11 @@ public class Store extends BaseTimeEntity { @Enumerated(EnumType.STRING) private Grade grade; + // 삭제 여부 + @Column + @ColumnDefault("false") + private boolean isDeleted; + //가게 사장님 연관관계 매핑 @OneToMany(mappedBy = "store") private List ceoInfos = new ArrayList(); diff --git a/src/main/java/com/readyvery/readyverydemo/domain/UserInfo.java b/src/main/java/com/readyvery/readyverydemo/domain/UserInfo.java index 9016dc0..8d1b9b7 100644 --- a/src/main/java/com/readyvery/readyverydemo/domain/UserInfo.java +++ b/src/main/java/com/readyvery/readyverydemo/domain/UserInfo.java @@ -4,6 +4,8 @@ import java.util.ArrayList; import java.util.List; +import org.hibernate.annotations.ColumnDefault; + import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -19,9 +21,11 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; import lombok.extern.slf4j.Slf4j; @Getter +@Setter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity @Builder @@ -89,6 +93,11 @@ public class UserInfo extends BaseTimeEntity { @Column private LocalDateTime lastLoginDate; + //포인트 + @Column + @ColumnDefault("0") + private Long point; + // 유저 장바구니 연관관계 매핑 @Builder.Default @OneToMany(mappedBy = "userInfo", cascade = CascadeType.ALL) @@ -104,11 +113,6 @@ public class UserInfo extends BaseTimeEntity { @Builder.Default private List coupons = new ArrayList(); - // 리프레시토큰 업데이트 - public void updateRefresh(String updateRefreshToken) { - this.refreshToken = updateRefreshToken; - } - public void updateRemoveUserDate() { this.status = true; this.deleteDate = LocalDateTime.now(); @@ -117,4 +121,9 @@ public void updateRemoveUserDate() { public void updateStatus(boolean status) { this.status = status; } + + public void updatePhone(String phoneNumber) { + this.phone = phoneNumber; + this.role = Role.USER; + } } diff --git a/src/main/java/com/readyvery/readyverydemo/domain/repository/CouponRepository.java b/src/main/java/com/readyvery/readyverydemo/domain/repository/CouponRepository.java index 208afd8..86c05cf 100644 --- a/src/main/java/com/readyvery/readyverydemo/domain/repository/CouponRepository.java +++ b/src/main/java/com/readyvery/readyverydemo/domain/repository/CouponRepository.java @@ -6,6 +6,8 @@ import org.springframework.data.jpa.repository.Lock; import com.readyvery.readyverydemo.domain.Coupon; +import com.readyvery.readyverydemo.domain.CouponDetail; +import com.readyvery.readyverydemo.domain.UserInfo; import jakarta.persistence.LockModeType; @@ -13,5 +15,5 @@ public interface CouponRepository extends JpaRepository { @Lock(LockModeType.OPTIMISTIC) Optional findById(Long id); - Long countByCouponDetailIdAndUserInfoId(Long couponDetailId, Long userInfoId); + Optional findByUserInfoAndCouponDetail(UserInfo userInfo, CouponDetail couponDetail); } diff --git a/src/main/java/com/readyvery/readyverydemo/domain/repository/OrderRepository.java b/src/main/java/com/readyvery/readyverydemo/domain/repository/OrderRepository.java deleted file mode 100644 index 95063ed..0000000 --- a/src/main/java/com/readyvery/readyverydemo/domain/repository/OrderRepository.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.readyvery.readyverydemo.domain.repository; - -import org.springframework.data.jpa.repository.JpaRepository; - -import com.readyvery.readyverydemo.domain.Order; - -public interface OrderRepository extends JpaRepository { -} diff --git a/src/main/java/com/readyvery/readyverydemo/domain/repository/OrdersRepository.java b/src/main/java/com/readyvery/readyverydemo/domain/repository/OrdersRepository.java index e37c255..7587241 100644 --- a/src/main/java/com/readyvery/readyverydemo/domain/repository/OrdersRepository.java +++ b/src/main/java/com/readyvery/readyverydemo/domain/repository/OrdersRepository.java @@ -5,13 +5,17 @@ import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import com.readyvery.readyverydemo.domain.Order; import com.readyvery.readyverydemo.domain.Progress; import com.readyvery.readyverydemo.domain.Store; import com.readyvery.readyverydemo.domain.UserInfo; +import jakarta.persistence.LockModeType; + public interface OrdersRepository extends JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) Optional findByOrderId(String orderId); Optional> findAllByUserInfo(UserInfo userInfo); diff --git a/src/main/java/com/readyvery/readyverydemo/domain/repository/PointRepository.java b/src/main/java/com/readyvery/readyverydemo/domain/repository/PointRepository.java new file mode 100644 index 0000000..4a99c2a --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/domain/repository/PointRepository.java @@ -0,0 +1,12 @@ +package com.readyvery.readyverydemo.domain.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.readyvery.readyverydemo.domain.Point; +import com.readyvery.readyverydemo.domain.UserInfo; + +public interface PointRepository extends JpaRepository { + List findAllByUserInfo(UserInfo userInfo); +} diff --git a/src/main/java/com/readyvery/readyverydemo/domain/repository/RefreshTokenRepository.java b/src/main/java/com/readyvery/readyverydemo/domain/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..1b8d365 --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/domain/repository/RefreshTokenRepository.java @@ -0,0 +1,15 @@ +package com.readyvery.readyverydemo.domain.repository; + +import java.util.Optional; + +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +import com.readyvery.readyverydemo.domain.RefreshToken; + +@Repository +public interface RefreshTokenRepository extends CrudRepository { + + Optional findByRefreshToken(String refreshToken); + +} diff --git a/src/main/java/com/readyvery/readyverydemo/global/Constant.java b/src/main/java/com/readyvery/readyverydemo/global/Constant.java index 0ab4c1e..b66c2fb 100644 --- a/src/main/java/com/readyvery/readyverydemo/global/Constant.java +++ b/src/main/java/com/readyvery/readyverydemo/global/Constant.java @@ -9,4 +9,5 @@ public class Constant { public static final Integer EMPTY_CART = 0; public static final Integer MAX_FASTORDER_SIZE = 5; public static final String TOSSPAYMENT_SUCCESS_MESSAGE = "결제 성공"; + public static final String MEMBERSHIP_PAYMENT_METHOD = "membership"; } diff --git a/src/main/java/com/readyvery/readyverydemo/global/exception/AuthErrorResponse.java b/src/main/java/com/readyvery/readyverydemo/global/exception/AuthErrorResponse.java new file mode 100644 index 0000000..d4c7cce --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/global/exception/AuthErrorResponse.java @@ -0,0 +1,11 @@ +package com.readyvery.readyverydemo.global.exception; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class AuthErrorResponse { + private boolean auth; + +} diff --git a/src/main/java/com/readyvery/readyverydemo/global/exception/ExceptionCode.java b/src/main/java/com/readyvery/readyverydemo/global/exception/ExceptionCode.java index 8fe9921..ec11657 100644 --- a/src/main/java/com/readyvery/readyverydemo/global/exception/ExceptionCode.java +++ b/src/main/java/com/readyvery/readyverydemo/global/exception/ExceptionCode.java @@ -31,7 +31,11 @@ public enum ExceptionCode { STORE_NOT_OPEN(401, "Store is not open."), CART_SOLD_OUT(400, "Cart is sold out."), CART_INOUT_NOT_MATCH(409, "Cart inout is not match."), - ORDER_ALREADY_END(400, "Order is already end."); + ORDER_ALREADY_END(400, "Order is already end."), + POINT_NOT_ENOUGH(400, "Point is not enough."), + INVALID_INPUT(400, "Invalid input."), + UNAUTHORIZED(400, "Already User"), + AUTH_ERROR(403, "Auth Error"); private int status; private String message; diff --git a/src/main/java/com/readyvery/readyverydemo/global/exception/GlobalExceptionAdvice.java b/src/main/java/com/readyvery/readyverydemo/global/exception/GlobalExceptionAdvice.java index dd98ee0..d90ca07 100644 --- a/src/main/java/com/readyvery/readyverydemo/global/exception/GlobalExceptionAdvice.java +++ b/src/main/java/com/readyvery/readyverydemo/global/exception/GlobalExceptionAdvice.java @@ -8,8 +8,15 @@ @RestControllerAdvice public class GlobalExceptionAdvice { @ExceptionHandler(BusinessLogicException.class) - public ResponseEntity handleException(BusinessLogicException exception) { - final ErrorResponse.Default response = ErrorResponse.of(exception.getExceptionCode()); - return new ResponseEntity<>(response, HttpStatus.valueOf(response.getStatus())); + public ResponseEntity handleBusinessLogicException(BusinessLogicException exception) { + // AUTH_ERROR 예외 처리 + if (exception.getExceptionCode() == ExceptionCode.AUTH_ERROR) { + AuthErrorResponse response = AuthErrorResponse.builder().auth(false).build(); + return ResponseEntity.status(HttpStatus.OK).body(response); + } + + // 다른 BusinessLogicException 예외 처리 + final ErrorResponse.Default defaultResponse = ErrorResponse.of(exception.getExceptionCode()); + return new ResponseEntity<>(defaultResponse, HttpStatus.valueOf(defaultResponse.getStatus())); } } diff --git a/src/main/java/com/readyvery/readyverydemo/security/jwt/dto/CustomUserDetails.java b/src/main/java/com/readyvery/readyverydemo/security/jwt/dto/CustomUserDetails.java index 4ce115d..d5f98dd 100644 --- a/src/main/java/com/readyvery/readyverydemo/security/jwt/dto/CustomUserDetails.java +++ b/src/main/java/com/readyvery/readyverydemo/security/jwt/dto/CustomUserDetails.java @@ -15,6 +15,7 @@ public class CustomUserDetails implements UserDetails { private Long id; private String email; private String password; + private String accessToken; private Collection authorities; @Override diff --git a/src/main/java/com/readyvery/readyverydemo/security/jwt/dto/UserLoginSuccessRes.java b/src/main/java/com/readyvery/readyverydemo/security/jwt/dto/UserLoginSuccessRes.java new file mode 100644 index 0000000..aa5f515 --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/security/jwt/dto/UserLoginSuccessRes.java @@ -0,0 +1,16 @@ +package com.readyvery.readyverydemo.security.jwt.dto; + +import com.readyvery.readyverydemo.domain.Role; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class UserLoginSuccessRes { + private boolean success; + private String message; + private String accessToken; + private String refreshToken; + private Role role; +} diff --git a/src/main/java/com/readyvery/readyverydemo/security/jwt/filter/JwtAuthenticationProcessingFilter.java b/src/main/java/com/readyvery/readyverydemo/security/jwt/filter/JwtAuthenticationProcessingFilter.java index d89ee72..ce92412 100644 --- a/src/main/java/com/readyvery/readyverydemo/security/jwt/filter/JwtAuthenticationProcessingFilter.java +++ b/src/main/java/com/readyvery/readyverydemo/security/jwt/filter/JwtAuthenticationProcessingFilter.java @@ -11,7 +11,9 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.OncePerRequestFilter; +import com.readyvery.readyverydemo.domain.RefreshToken; import com.readyvery.readyverydemo.domain.UserInfo; +import com.readyvery.readyverydemo.domain.repository.RefreshTokenRepository; import com.readyvery.readyverydemo.domain.repository.UserRepository; import com.readyvery.readyverydemo.security.jwt.dto.CustomUserDetails; import com.readyvery.readyverydemo.security.jwt.service.JwtService; @@ -31,6 +33,7 @@ public class JwtAuthenticationProcessingFilter extends OncePerRequestFilter { private final JwtService jwtService; private final UserRepository userRepository; + private final RefreshTokenRepository refreshTokenRepository; private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper(); @@ -46,7 +49,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse // -> RefreshToken이 없거나 유효하지 않다면(DB에 저장된 RefreshToken과 다르다면) null을 반환 // 사용자의 요청 헤더에 RefreshToken이 있는 경우는, AccessToken이 만료되어 요청한 경우밖에 없다. // 따라서, 위의 경우를 제외하면 추출한 refreshToken은 모두 null - String refreshToken = jwtService.extractRefreshTokenFromCookies(request) + String refreshToken = jwtService.extractRefreshToken(request) .filter(jwtService::isTokenValid) .orElse(null); @@ -75,11 +78,13 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse * 그 후 JwtService.sendAccessTokenAndRefreshToken()으로 응답 헤더에 보내기 */ public void checkRefreshTokenAndReIssueAccessToken(HttpServletResponse response, String refreshToken) { - userRepository.findByRefreshToken(refreshToken) + refreshTokenRepository.findByRefreshToken(refreshToken) + .map(RefreshToken::getId) // RefreshToken 객체에서 ID를 추출합니다. + .flatMap(userRepository::findByEmail) // 추출된 ID를 이용하여 user를 조회합니다. .ifPresent(user -> { String reIssuedRefreshToken = reIssueRefreshToken(user); jwtService.sendAccessAndRefreshToken(response, jwtService.createAccessToken(user.getEmail()), - reIssuedRefreshToken); + reIssuedRefreshToken, user.getRole()); }); } @@ -90,8 +95,22 @@ public void checkRefreshTokenAndReIssueAccessToken(HttpServletResponse response, */ private String reIssueRefreshToken(UserInfo userInfo) { String reIssuedRefreshToken = jwtService.createRefreshToken(); - userInfo.updateRefresh(reIssuedRefreshToken); - userRepository.saveAndFlush(userInfo); + + RefreshToken refreshToken = refreshTokenRepository.findById(userInfo.getEmail()) + .map(token -> { + // 이미 존재하는 토큰이 있으면, 새로 발급받은 리프레시 토큰으로 업데이트 + token.update(reIssuedRefreshToken); + return token; + }) + .orElseGet(() -> { + // 새로운 토큰 생성 + return RefreshToken.builder() + .id(userInfo.getEmail()) + .refreshToken(reIssuedRefreshToken) + .build(); + }); + + refreshTokenRepository.save(refreshToken); return reIssuedRefreshToken; } @@ -106,11 +125,11 @@ private String reIssueRefreshToken(UserInfo userInfo) { public void checkAccessTokenAndAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - jwtService.extractAccessTokenFromCookies(request) + jwtService.extractAccessToken(request) .filter(jwtService::isTokenValid) .ifPresent(accessToken -> jwtService.extractEmail(accessToken) .ifPresent(email -> userRepository.findByEmail(email) - .ifPresent(this::saveAuthentication))); + .ifPresent(user -> saveAuthentication(user, accessToken)))); filterChain.doFilter(request, response); } @@ -130,12 +149,13 @@ public void checkAccessTokenAndAuthentication(HttpServletRequest request, HttpSe * SecurityContextHolder.getContext()로 SecurityContext를 꺼낸 후, * setAuthentication()을 이용하여 위에서 만든 Authentication 객체에 대한 인증 허가 처리 */ - public void saveAuthentication(UserInfo myUser) { + public void saveAuthentication(UserInfo myUser, String accessToken) { CustomUserDetails userDetailsUser = CustomUserDetails.builder() .id(myUser.getId()) .email(myUser.getEmail()) .password("readyvery") + .accessToken(accessToken) .authorities(Collections.singletonList(new SimpleGrantedAuthority(myUser.getRole().toString()))) .build(); diff --git a/src/main/java/com/readyvery/readyverydemo/security/jwt/service/JwtService.java b/src/main/java/com/readyvery/readyverydemo/security/jwt/service/JwtService.java index 33172b2..b3d9b08 100644 --- a/src/main/java/com/readyvery/readyverydemo/security/jwt/service/JwtService.java +++ b/src/main/java/com/readyvery/readyverydemo/security/jwt/service/JwtService.java @@ -1,133 +1,39 @@ package com.readyvery.readyverydemo.security.jwt.service; -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.Arrays; -import java.util.Date; -import java.util.NoSuchElementException; import java.util.Optional; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; +import com.readyvery.readyverydemo.domain.Role; -import com.auth0.jwt.JWT; -import com.auth0.jwt.algorithms.Algorithm; -import com.readyvery.readyverydemo.domain.UserInfo; -import com.readyvery.readyverydemo.domain.repository.UserRepository; - -import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -@Service -@RequiredArgsConstructor -@Getter -@Slf4j -public class JwtService { - - /** - * JWT의 Subject와 Claim으로 email 사용 -> 클레임의 name을 "email"으로 설정 - * - */ - private static final String ACCESS_TOKEN_SUBJECT = "AccessToken"; - private static final String REFRESH_TOKEN_SUBJECT = "RefreshToken"; - private static final String EMAIL_CLAIM = "email"; - - private static final String USER_NUMBER = "userNumber"; - private final UserRepository userRepository; - @Value("${jwt.secretKey}") - private String secretKey; - @Value("${jwt.access.expiration}") - private Long accessTokenExpirationPeriod; - @Value("${jwt.refresh.expiration}") - private Long refreshTokenExpirationPeriod; - @Value("${jwt.access.cookie}") - private String accessCookie; - @Value("${jwt.refresh.cookie}") - private String refreshCookie; - @Value("${jwt.redirect-uri}") - private String frontendUrl; - @Value("${jwt.access.cookie.domain}") - private String accessCookieDomain; - @Value("${jwt.refresh.cookie.domain}") - private String refreshCookieDomain; +public interface JwtService { /** * AccessToken 생성 메소드 */ - public String createAccessToken(String email) { - UserInfo userInfo = userRepository.findByEmail(email) - .orElseThrow(() -> new IllegalArgumentException("이메일에 해당하는 유저가 없습니다.")); - Instant now = Instant.now(); - Instant expirationTime = now.plus(accessTokenExpirationPeriod, ChronoUnit.SECONDS); - - return JWT.create() // JWT 토큰을 생성하는 빌더 반환 - .withSubject(ACCESS_TOKEN_SUBJECT) // JWT의 Subject 지정 -> AccessToken이므로 AccessToken - .withExpiresAt(Date.from(expirationTime)) // 토큰 만료 시간 설정 - - //클레임으로는 저희는 email 하나만 사용합니다. - //추가적으로 식별자나, 이름 등의 정보를 더 추가하셔도 됩니다. - //추가하실 경우 .withClaim(클래임 이름, 클래임 값) 으로 설정해주시면 됩니다 - .withClaim(EMAIL_CLAIM, email) - .withClaim(USER_NUMBER, userInfo.getId()) - .sign(Algorithm.HMAC512(secretKey)); // HMAC512 알고리즘 사용, application-jwt.yml에서 지정한 secret 키로 암호화 - } + String createAccessToken(String email); /** * RefreshToken 생성 * RefreshToken은 Claim에 email도 넣지 않으므로 withClaim() X */ - public String createRefreshToken() { - Instant now = Instant.now(); - Instant expirationTime = now.plus(refreshTokenExpirationPeriod, ChronoUnit.SECONDS); - return JWT.create() - .withSubject(REFRESH_TOKEN_SUBJECT) - .withExpiresAt(Date.from(expirationTime)) - .sign(Algorithm.HMAC512(secretKey)); - } + String createRefreshToken(); /** * AccessToken + RefreshToken 헤더에 실어서 보내기 */ - public void sendAccessAndRefreshToken(HttpServletResponse response, String accessToken, String refreshToken) { - - response.setStatus(HttpServletResponse.SC_OK); - - setAccessTokenCookie(response, accessToken); - setRefreshTokenCookie(response, refreshToken); - - } + void sendAccessAndRefreshToken(HttpServletResponse response, String accessToken, String refreshToken, Role role); /** * 쿠키에서 RefreshToken 추출 */ - public Optional extractRefreshTokenFromCookies(HttpServletRequest request) { - Cookie[] cookies = request.getCookies(); - if (cookies != null) { - return Arrays.stream(cookies) - .filter(cookie -> refreshCookie.equals(cookie.getName())) // 올바른 필터링 조건 - .findFirst() - .map(Cookie::getValue); - } - return Optional.empty(); - } + Optional extractRefreshToken(HttpServletRequest request); /** * 쿠키에서 AccessToken 추출 */ - public Optional extractAccessTokenFromCookies(HttpServletRequest request) { - Cookie[] cookies = request.getCookies(); - if (cookies != null) { - return Arrays.stream(cookies) - .filter(cookie -> accessCookie.equals(cookie.getName())) // 올바른 필터링 조건 - .findFirst() - .map(Cookie::getValue); - } - return Optional.empty(); - } + Optional extractAccessToken(HttpServletRequest request); /** * AccessToken에서 Email 추출 @@ -136,84 +42,12 @@ public Optional extractAccessTokenFromCookies(HttpServletRequest request * 유효하다면 getClaim()으로 이메일 추출 * 유효하지 않다면 빈 Optional 객체 반환 */ - public Optional extractEmail(String accessToken) { - try { - // 토큰 유효성 검사하는 데에 사용할 알고리즘이 있는 JWT verifier builder 반환 - return Optional.ofNullable(JWT.require(Algorithm.HMAC512(secretKey)) - .build() // 반환된 빌더로 JWT verifier 생성 - .verify(accessToken) // accessToken을 검증하고 유효하지 않다면 예외 발생 - .getClaim(EMAIL_CLAIM) // claim(Emial) 가져오기 - .asString()); - } catch (Exception e) { - log.error("액세스 토큰이 유효하지 않습니다."); - return Optional.empty(); - } - } - - /** - * AccessToken 헤더 설정 - */ - public void setAccessTokenCookie(HttpServletResponse response, String accessToken) { - Cookie accessTokenCookie = new Cookie(accessCookie, accessToken); // 쿠키 생성 - //accessTokenCookie.setHttpOnly(true); // JavaScript가 쿠키를 읽는 것을 방지 - accessTokenCookie.setPath("/"); // 쿠키 경로 설정 - - accessTokenCookie.setDomain(accessCookieDomain); - - // 필요한 경우 Secure 플래그 설정 (HTTPS에서만 쿠키 전송) - //accessTokenCookie.setSecure(true); - - // 필요한 경우 동일한 사이트 속성 설정 (쿠키 전송에 대한 제한) - // accessTokenCookie.setSameSite("Strict"); - - // 도메인 설정 - //accessTokenCookie.setDomain("localhost"); - - // 쿠키 만료 시간 설정 (예: 액세스 토큰 만료 시간과 같게 설정) - accessTokenCookie.setMaxAge(accessTokenExpirationPeriod.intValue()); // 초 단위로 설정 - response.addCookie(accessTokenCookie); // 응답에 쿠키 추가 - } - - /** - * RefreshToken 헤더 설정 - */ - public void setRefreshTokenCookie(HttpServletResponse response, String refreshToken) { - Cookie refreshTokenCookie = new Cookie(refreshCookie, refreshToken); // 쿠키 생성 - refreshTokenCookie.setHttpOnly(true); // JavaScript가 쿠키를 읽는 것을 방지 - refreshTokenCookie.setPath("/api/v1/refresh/token"); // 쿠키 경로 설정 - - refreshTokenCookie.setDomain(refreshCookieDomain); - - // 필요한 경우 Secure 플래그 설정 (HTTPS에서만 쿠키 전송) - // accessTokenCookie.setSecure(true); - - // 필요한 경우 동일한 사이트 속성 설정 (쿠키 전송에 대한 제한) - // accessTokenCookie.setSameSite("Strict"); - - // 쿠키 만료 시간 설정 (예: 액세스 토큰 만료 시간과 같게 설정) - refreshTokenCookie.setMaxAge(refreshTokenExpirationPeriod.intValue()); // 초 단위로 설정 - response.addCookie(refreshTokenCookie); // 응답에 쿠키 추가 - } + Optional extractEmail(String accessToken); /** * RefreshToken DB 저장(업데이트) */ - public void updateRefreshToken(String email, String refreshToken) { - UserInfo user = userRepository.findByEmail(email) - .orElseThrow(() -> new NoSuchElementException("일치하는 회원이 없습니다.")); - - user.updateRefresh(refreshToken); - userRepository.save(user); - } + boolean isTokenValid(String token); - public boolean isTokenValid(String token) { - try { - JWT.require(Algorithm.HMAC512(secretKey)).build().verify(token); - return true; - } catch (Exception e) { - log.error("유효하지 않은 토큰입니다. {}", e.getMessage()); - return false; - } - } } diff --git a/src/main/java/com/readyvery/readyverydemo/security/jwt/service/JwtServiceImpl.java b/src/main/java/com/readyvery/readyverydemo/security/jwt/service/JwtServiceImpl.java new file mode 100644 index 0000000..4cdaa28 --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/security/jwt/service/JwtServiceImpl.java @@ -0,0 +1,122 @@ +package com.readyvery.readyverydemo.security.jwt.service; + +import static com.readyvery.readyverydemo.config.JwtConfig.*; + +import java.util.Optional; + +import org.springframework.stereotype.Service; + +import com.auth0.jwt.JWT; +import com.readyvery.readyverydemo.config.JwtConfig; +import com.readyvery.readyverydemo.domain.Role; +import com.readyvery.readyverydemo.domain.UserInfo; +import com.readyvery.readyverydemo.security.jwt.service.create.JwtTokenGenerator; +import com.readyvery.readyverydemo.security.jwt.service.extract.ExtractToken; +import com.readyvery.readyverydemo.security.jwt.service.sendmanger.JwtTokenizer; +import com.readyvery.readyverydemo.src.user.UserServiceFacade; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Getter +@Slf4j +public class JwtServiceImpl implements JwtService { + + private final UserServiceFacade userServiceFacade; + private final JwtTokenizer jwtTokenizer; + private final JwtTokenGenerator hmacJwtTokenGeneratorImpl; + private final JwtConfig jwtConfig; + private final ExtractToken extractToken; + + /** + * AccessToken 생성 메소드 + */ + @Override + public String createAccessToken(String email) { + UserInfo userInfo = userServiceFacade.getUserInfoByEmail(email); + return hmacJwtTokenGeneratorImpl.generateAccessToken(email, userInfo.getId()); + } + + /** + * RefreshToken 생성 + * RefreshToken은 Claim에 email도 넣지 않으므로 withClaim() X + */ + @Override + public String createRefreshToken() { + + return hmacJwtTokenGeneratorImpl.generateRefreshToken(); + } + + /** + * AccessToken + RefreshToken 헤더에 실어서 보내기 + */ + @Override + public void sendAccessAndRefreshToken(HttpServletResponse response, String accessToken, String refreshToken, + Role role) { + + jwtTokenizer.addAccessTokenCookie(response, accessToken); + + jwtTokenizer.addRefreshTokenCookie(response, refreshToken); + log.info("Access Token, Refresh Token 헤더 설정 완료"); + } + + /** + * RefreshToken 추출 + */ + @Override + public Optional extractRefreshToken(HttpServletRequest request) { + return extractToken.extractTokenCookie(request, jwtConfig.getRefreshTokenName()); + } + + /** + * AccessToken 추출 + */ + @Override + public Optional extractAccessToken(HttpServletRequest request) { + // "Authorization" 헤더를 확인합니다. + //String authorizationHeader = request.getHeader(AUTHORIZATION); + // "Authorization" 헤더가 존재하면, 헤더에서 토큰을 추출합니다. + //if (authorizationHeader != null && !authorizationHeader.isEmpty()) { + return extractToken.extractTokenHeader(request, AUTHORIZATION); + //} + + // "Authorization" 헤더가 존재하지 않으면, 쿠키에서 토큰을 추출합니다. + //return extractToken.extractTokenCookie(request, jwtConfig.getAccessTokenName()); + + } + + /** + * AccessToken에서 Email 추출 + * 추출 전에 JWT.require()로 검증기 생성 + * verify로 AceessToken 검증 후 + * 유효하다면 getClaim()으로 이메일 추출 + * 유효하지 않다면 빈 Optional 객체 반환 + */ + @Override + public Optional extractEmail(String accessToken) { + try { + // 토큰 유효성 검사하는 데에 사용할 알고리즘이 있는 JWT verifier builder 반환 + return jwtTokenizer.verifyAccessToken(accessToken); + } catch (Exception e) { + log.error("액세스 토큰이 유효하지 않습니다."); + return Optional.empty(); + } + } + + @Override + public boolean isTokenValid(String token) { + try { + JWT.require(jwtConfig.getAlgorithm()).build().verify(token); + return true; + } catch (Exception e) { + log.error("유효하지 않은 토큰입니다. {}", e.getMessage()); + return false; + } + } + +} diff --git a/src/main/java/com/readyvery/readyverydemo/security/jwt/service/create/HmacJwtTokenGeneratorImpl.java b/src/main/java/com/readyvery/readyverydemo/security/jwt/service/create/HmacJwtTokenGeneratorImpl.java new file mode 100644 index 0000000..827869c --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/security/jwt/service/create/HmacJwtTokenGeneratorImpl.java @@ -0,0 +1,45 @@ +package com.readyvery.readyverydemo.security.jwt.service.create; + +import static com.readyvery.readyverydemo.config.JwtConfig.*; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; + +import org.springframework.stereotype.Component; + +import com.auth0.jwt.JWT; +import com.readyvery.readyverydemo.config.JwtConfig; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Component +public class HmacJwtTokenGeneratorImpl implements JwtTokenGenerator { + + private final JwtConfig jwtConfig; + + @Override + public String generateAccessToken(String email, Long id) { + // 토큰 생성 로직 + Instant now = Instant.now(); + Instant expirationTime = now.plus(jwtConfig.getAccessTokenExpirationPeriod(), ChronoUnit.SECONDS); + return JWT.create() + .withSubject(ACCESS_TOKEN_SUBJECT) + .withExpiresAt(Date.from(expirationTime)) + .withClaim(EMAIL_CLAIM, email) + .withClaim(USER_NUMBER, id) + .sign(jwtConfig.getAlgorithm()); + } + + @Override + public String generateRefreshToken() { + // 토큰 생성 로직 + Instant now = Instant.now(); + Instant expirationTime = now.plus(jwtConfig.getRefreshTokenExpirationPeriod(), ChronoUnit.SECONDS); + return JWT.create() + .withSubject(REFRESH_TOKEN_SUBJECT) + .withExpiresAt(Date.from(expirationTime)) + .sign(jwtConfig.getAlgorithm()); + } +} diff --git a/src/main/java/com/readyvery/readyverydemo/security/jwt/service/create/JwtTokenGenerator.java b/src/main/java/com/readyvery/readyverydemo/security/jwt/service/create/JwtTokenGenerator.java new file mode 100644 index 0000000..b2bc76d --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/security/jwt/service/create/JwtTokenGenerator.java @@ -0,0 +1,7 @@ +package com.readyvery.readyverydemo.security.jwt.service.create; + +public interface JwtTokenGenerator { + String generateAccessToken(String email, Long id); + + String generateRefreshToken(); +} diff --git a/src/main/java/com/readyvery/readyverydemo/security/jwt/service/extract/ExtractToken.java b/src/main/java/com/readyvery/readyverydemo/security/jwt/service/extract/ExtractToken.java new file mode 100644 index 0000000..a66cc3e --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/security/jwt/service/extract/ExtractToken.java @@ -0,0 +1,33 @@ +package com.readyvery.readyverydemo.security.jwt.service.extract; + +import static com.readyvery.readyverydemo.config.JwtConfig.*; + +import java.util.Arrays; +import java.util.Optional; + +import org.springframework.stereotype.Component; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; + +@Component +public class ExtractToken { + + public Optional extractTokenCookie(HttpServletRequest request, String tokenName) { + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + return Arrays.stream(cookies) + .filter(cookie -> tokenName.equals(cookie.getName())) + .findFirst() + .map(Cookie::getValue); + } + return Optional.empty(); + } + + public Optional extractTokenHeader(HttpServletRequest request, String tokenName) { + return Optional.ofNullable(request.getHeader(tokenName)) + .filter(verifyToken -> verifyToken.startsWith(BEARER)) + .map(verifyToken -> verifyToken.replace(BEARER, "")); + } + +} diff --git a/src/main/java/com/readyvery/readyverydemo/security/jwt/service/sendmanger/JwtTokenizer.java b/src/main/java/com/readyvery/readyverydemo/security/jwt/service/sendmanger/JwtTokenizer.java new file mode 100644 index 0000000..284db7a --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/security/jwt/service/sendmanger/JwtTokenizer.java @@ -0,0 +1,54 @@ +package com.readyvery.readyverydemo.security.jwt.service.sendmanger; + +import static com.readyvery.readyverydemo.config.JwtConfig.*; + +import java.util.Optional; + +import org.springframework.context.annotation.Configuration; + +import com.auth0.jwt.JWT; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.readyvery.readyverydemo.config.JwtConfig; +import com.readyvery.readyverydemo.security.jwt.service.create.JwtTokenGenerator; + +import jakarta.servlet.http.HttpServletResponse; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Configuration +@Slf4j +@Getter +@RequiredArgsConstructor +public class JwtTokenizer { + + private final JwtTokenGenerator tokenGenerator; + private final TokenSendManager tokenSendManager; + private final ObjectMapper objectMapper; + private final JwtConfig jwtConfig; + + public void addAccessTokenCookie(HttpServletResponse response, String accessToken) { + tokenSendManager.addTokenCookie(response, jwtConfig.getAccessTokenName(), accessToken, "/", + jwtConfig.getAccessTokenExpirationPeriod().intValue(), false); + } + + public void addRefreshTokenCookie(HttpServletResponse response, String refreshToken) { + tokenSendManager.addTokenCookie(response, jwtConfig.getRefreshTokenName(), refreshToken, + "/api/v1/refresh/token", + jwtConfig.getRefreshTokenExpirationPeriod().intValue(), true); + } + + // public void addAccessRefreshTokenResponseBody(String accessToken, + // String refreshToken, Role role) { + // tokenSendManager.addTokenResponseBody(accessToken, refreshToken, role); + // + // } + + public Optional verifyAccessToken(String accessToken) { + return Optional.ofNullable(JWT.require(jwtConfig.getAlgorithm()) + .build() + .verify(accessToken) + .getClaim(EMAIL_CLAIM) + .asString()); + } +} diff --git a/src/main/java/com/readyvery/readyverydemo/security/jwt/service/sendmanger/TokenSendManager.java b/src/main/java/com/readyvery/readyverydemo/security/jwt/service/sendmanger/TokenSendManager.java new file mode 100644 index 0000000..e0e7e9b --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/security/jwt/service/sendmanger/TokenSendManager.java @@ -0,0 +1,43 @@ +package com.readyvery.readyverydemo.security.jwt.service.sendmanger; + +import org.springframework.stereotype.Component; + +import com.readyvery.readyverydemo.config.JwtConfig; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RequiredArgsConstructor +@Component +@Slf4j +public class TokenSendManager { + private final JwtConfig jwtConfig; + + public void addTokenCookie(HttpServletResponse response, String name, String value, String path, int maxAge, + boolean httpOnly) { + Cookie cookie = new Cookie(name, value); + cookie.setHttpOnly(httpOnly); + cookie.setPath(path); + cookie.setDomain(jwtConfig.getCookieDomain()); + cookie.setMaxAge(maxAge); + response.addCookie(cookie); + + } + + // public ResponseEntity addTokenResponseBody(String accessToken, String refreshToken, + // Role role) { + // UserLoginSuccessRes userLoginSuccessRes = UserLoginSuccessRes.builder() + // .success(true) + // .message("로그인 성공") + // .accessToken(accessToken) + // .refreshToken(refreshToken) + // .role(role) + // .build(); + // + // return ResponseEntity.ok() + // .contentType(MediaType.APPLICATION_JSON) + // .body(userLoginSuccessRes); + // } +} diff --git a/src/main/java/com/readyvery/readyverydemo/security/oauth2/CustomRequestEntityConverter.java b/src/main/java/com/readyvery/readyverydemo/security/oauth2/CustomRequestEntityConverter.java new file mode 100644 index 0000000..4a7123a --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/security/oauth2/CustomRequestEntityConverter.java @@ -0,0 +1,76 @@ +package com.readyvery.readyverydemo.security.oauth2; + +import java.io.IOException; +import java.io.StringReader; +import java.security.PrivateKey; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; +import org.springframework.core.convert.converter.Converter; +import org.springframework.http.RequestEntity; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequestEntityConverter; +import org.springframework.util.MultiValueMap; + +import com.readyvery.readyverydemo.config.OauthConfig; + +import io.jsonwebtoken.Jwts; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Getter +@RequiredArgsConstructor +public class CustomRequestEntityConverter implements Converter> { + + private final OAuth2AuthorizationCodeGrantRequestEntityConverter defaultConverter; + private final OauthConfig oauthConfig; + + @Override + public RequestEntity convert(OAuth2AuthorizationCodeGrantRequest req) { + RequestEntity entity = defaultConverter.convert(req); + String registrationId = req.getClientRegistration().getRegistrationId(); + MultiValueMap params = (MultiValueMap)entity.getBody(); + if (registrationId.contains("apple")) { + try { + params.set("client_secret", createClientSecret()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + return new RequestEntity<>(params, entity.getHeaders(), + entity.getMethod(), entity.getUrl()); + } + + public PrivateKey getPrivateKey() throws IOException { + System.out.println("@@@@@@@@@@@@@@@oauthConfig = " + oauthConfig.getPrivateKeyString()); + PEMParser pemParser = new PEMParser(new StringReader(oauthConfig.getPrivateKeyString())); + PrivateKeyInfo object = (PrivateKeyInfo)pemParser.readObject(); + JcaPEMKeyConverter converter = new JcaPEMKeyConverter(); + return converter.getPrivateKey(object); + } + + public String createClientSecret() throws IOException { + Date expirationDate = Date.from(LocalDateTime.now().plusDays(30).atZone(ZoneId.systemDefault()).toInstant()); + Map jwtHeader = new HashMap<>(); + jwtHeader.put("kid", oauthConfig.getAppleKeyId()); + jwtHeader.put("alg", "ES256"); + + return Jwts.builder() + .setHeaderParams(jwtHeader) + .setIssuer(oauthConfig.getAppleTeamId()) + .setIssuedAt(new Date(System.currentTimeMillis())) // 발행 시간 - UNIX 시간 + .setExpiration(expirationDate) // 만료 시간 + .setAudience(oauthConfig.getAppleUrl()) + .setSubject(oauthConfig.getAppleClientId()) + .signWith(getPrivateKey()) + .compact(); + } +} diff --git a/src/main/java/com/readyvery/readyverydemo/security/oauth2/OAuthAttributes.java b/src/main/java/com/readyvery/readyverydemo/security/oauth2/OAuthAttributes.java index 024e26e..c5b963a 100644 --- a/src/main/java/com/readyvery/readyverydemo/security/oauth2/OAuthAttributes.java +++ b/src/main/java/com/readyvery/readyverydemo/security/oauth2/OAuthAttributes.java @@ -5,6 +5,8 @@ import com.readyvery.readyverydemo.domain.Role; import com.readyvery.readyverydemo.domain.SocialType; import com.readyvery.readyverydemo.domain.UserInfo; +import com.readyvery.readyverydemo.security.oauth2.userinfo.AppleOAuth2UserInfo; +import com.readyvery.readyverydemo.security.oauth2.userinfo.GoogleOAuth2UserInfo; import com.readyvery.readyverydemo.security.oauth2.userinfo.KakaoOAuth2UserInfo; import com.readyvery.readyverydemo.security.oauth2.userinfo.OAuth2UserInfo; @@ -36,7 +38,14 @@ public OAuthAttributes(String nameAttributeKey, OAuth2UserInfo oauth2UserInfo) { public static OAuthAttributes of(SocialType socialType, String userNameAttributeName, Map attributes) { - return ofKakao(userNameAttributeName, attributes); + if (socialType == SocialType.KAKAO) { + return ofKakao(userNameAttributeName, attributes); + } + if (socialType == SocialType.APPLE) { + return ofApple(userNameAttributeName, attributes); + } + + return ofGoogle(userNameAttributeName, attributes); } @@ -47,6 +56,20 @@ private static OAuthAttributes ofKakao(String userNameAttributeName, Map attributes) { + return OAuthAttributes.builder() + .nameAttributeKey(userNameAttributeName) + .oauth2UserInfo(new GoogleOAuth2UserInfo(attributes)) + .build(); + } + + public static OAuthAttributes ofApple(String userNameAttributeName, Map attributes) { + return OAuthAttributes.builder() + .nameAttributeKey(userNameAttributeName) + .oauth2UserInfo(new AppleOAuth2UserInfo(attributes)) + .build(); + } + /** * of메소드로 OAuthAttributes 객체가 생성되어, 유저 정보들이 담긴 OAuth2UserInfo가 소셜 타입별로 주입된 상태 * OAuth2UserInfo에서 socialId(식별값), nickname, imageUrl을 가져와서 build @@ -54,6 +77,7 @@ private static OAuthAttributes ofKakao(String userNameAttributeName, Map delegate = new DefaultOAuth2UserService(); + SocialType socialType = getSocialType(registrationId); + String userNameAttributeName = userRequest.getClientRegistration() + .getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName(); // OAuth2 로그인 시 키(PK)가 되는값 + Map attributes; /** * DefaultOAuth2UserService 객체를 생성하여, loadUser(userRequest)를 통해 DefaultOAuth2User 객체를 생성 후 반환 * DefaultOAuth2UserService의 loadUser()는 소셜 로그인 API의 사용자 정보 제공 URI로 요청을 보내서 * 사용자 정보를 얻은 후, 이를 통해 DefaultOAuth2User 객체를 생성 후 반환한다. * 결과적으로, OAuth2User는 OAuth 서비스에서 가져온 유저 정보를 담고 있는 유저 */ - OAuth2UserService delegate = new DefaultOAuth2UserService(); - OAuth2User oAuth2User = delegate.loadUser(userRequest); - - /** - * userRequest에서 registrationId 추출 후 registrationId으로 SocialType 저장 - * http://localhost:8080/oauth2/authorization/kakao에서 kakao가 registrationId - * userNameAttributeName은 이후에 nameAttributeKey로 설정된다. - */ - String registrationId = userRequest.getClientRegistration().getRegistrationId(); - SocialType socialType = getSocialType(registrationId); - String userNameAttributeName = userRequest.getClientRegistration() - .getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName(); // OAuth2 로그인 시 키(PK)가 되는 값 - Map attributes = oAuth2User.getAttributes(); // 소셜 로그인에서 API가 제공하는 userInfo의 Json 값(유저 정보들) - - // socialType에 따라 유저 정보를 통해 OAuthAttributes 객체 생성 - OAuthAttributes extractAttributes = OAuthAttributes.of(socialType, userNameAttributeName, attributes); - - UserInfo createdUser = getUser(extractAttributes, socialType); // getUser() 메소드로 User 객체 생성 후 반환 - - // DefaultOAuth2User를 구현한 CustomOAuth2User 객체를 생성해서 반환 - return new CustomOAuth2User( - Collections.singleton(new SimpleGrantedAuthority(createdUser.getRole().getKey())), - attributes, - extractAttributes.getNameAttributeKey(), - createdUser.getEmail(), - createdUser.getRole() - ); + if (registrationId.contains(APPLE_NAME)) { + // Apple 로그인의 경우 JWT 토큰에서 사용자 정보를 디코드 + String idToken = userRequest.getAdditionalParameters().get("id_token").toString(); + attributes = decodeJwtTokenPayload(idToken); + attributes.put("id_token", idToken); + + System.out.println("attributes = " + attributes); + System.out.println("userNameAttributeName = " + userNameAttributeName); + + // socialType에 따라 유저 정보를 통해 OAuthAttributes 객체 생성 + OAuthAttributes extractAttributes = OAuthAttributes.of(socialType, userNameAttributeName, attributes); + + System.out.println("extractAttributes = " + extractAttributes); + UserInfo createdUser = getUser(extractAttributes, socialType); // getUser() 메소드로 User 객체 생성 후 반환 + System.out.println("createdUser = " + createdUser); + // CustomOAuth2User 객체 생성 + return new CustomOAuth2User( + Collections.singleton(new SimpleGrantedAuthority(createdUser.getRole().getKey())), + attributes, + extractAttributes.getNameAttributeKey(), + createdUser.getEmail(), + createdUser.getRole() + ); + } else { + OAuth2User oAuth2User = delegate.loadUser(userRequest); + + /** + * userRequest에서 registrationId 추출 후 registrationId으로 SocialType 저장 + * http://localhost:8080/oauth2/authorization/kakao에서 kakao가 registrationId + * userNameAttributeName은 이후에 nameAttributeKey로 설정된다. + */ + + attributes = oAuth2User.getAttributes(); // 소셜 로그인에서 API가 제공하는 userInfo의 Json 값(유저 정보들) + + // socialType에 따라 유저 정보를 통해 OAuthAttributes 객체 생성 + OAuthAttributes extractAttributes = OAuthAttributes.of(socialType, userNameAttributeName, attributes); + + UserInfo createdUser = getUser(extractAttributes, socialType); // getUser() 메소드로 User 객체 생성 후 반환 + + // DefaultOAuth2User를 구현한 CustomOAuth2User 객체를 생성해서 반환 + return new CustomOAuth2User( + Collections.singleton(new SimpleGrantedAuthority(createdUser.getRole().getKey())), + attributes, + extractAttributes.getNameAttributeKey(), + createdUser.getEmail(), + createdUser.getRole() + ); + } } private SocialType getSocialType(String registrationId) { - return SocialType.KAKAO; + if (KAKAO_NAME.equals(registrationId)) { + return SocialType.KAKAO; + } else if (APPLE_NAME.contains(registrationId)) { + + return SocialType.APPLE; + } + return SocialType.GOOGLE; } /** @@ -76,6 +113,7 @@ private SocialType getSocialType(String registrationId) { * 만약 찾은 회원이 있다면, 그대로 반환하고 없다면 saveUser()를 호출하여 회원을 저장한다. */ private UserInfo getUser(OAuthAttributes attributes, SocialType socialType) { + UserInfo findUser = userRepository.findBySocialTypeAndSocialId(socialType, attributes.getOauth2UserInfo().getId()).orElse(null); @@ -93,7 +131,27 @@ private UserInfo getUser(OAuthAttributes attributes, SocialType socialType) { * 생성된 User 객체를 DB에 저장 : socialType, socialId, email, role 값만 있는 상태 */ private UserInfo saveUser(OAuthAttributes attributes, SocialType socialType) { + UserInfo createdUser = attributes.toEntity(socialType, attributes.getOauth2UserInfo()); return userRepository.save(createdUser); } + + private Map decodeJwtTokenPayload(String jwtToken) { + Map jwtClaims = new HashMap<>(); + try { + String[] parts = jwtToken.split("\\."); + Base64.Decoder decoder = Base64.getUrlDecoder(); + + byte[] decodedBytes = decoder.decode(parts[1].getBytes(StandardCharsets.UTF_8)); + String decodedString = new String(decodedBytes, StandardCharsets.UTF_8); + ObjectMapper mapper = new ObjectMapper(); + + Map map = mapper.readValue(decodedString, Map.class); + jwtClaims.putAll(map); + + } catch (JsonProcessingException e) { + // logger.error("decodeJwtToken: {}-{} / jwtToken : {}", e.getMessage(), e.getCause(), jwtToken); + } + return jwtClaims; + } } diff --git a/src/main/java/com/readyvery/readyverydemo/security/oauth2/userinfo/AppleOAuth2UserInfo.java b/src/main/java/com/readyvery/readyverydemo/security/oauth2/userinfo/AppleOAuth2UserInfo.java new file mode 100644 index 0000000..7d2be3e --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/security/oauth2/userinfo/AppleOAuth2UserInfo.java @@ -0,0 +1,58 @@ +package com.readyvery.readyverydemo.security.oauth2.userinfo; + +import java.util.Map; +import java.util.Optional; + +public class AppleOAuth2UserInfo extends OAuth2UserInfo { + + public AppleOAuth2UserInfo(Map attributes) { + super(attributes); + + } + + @Override + public String getId() { + return String.valueOf(attributes.get("sub")); + } + + @Override + public String getNickName() { + // Map account = (Map)attributes.get("user"); + // Map profile = (Map)account.get("name"); + // + // if (account == null || profile == null) { + // return null; + // } + + //return (String)profile.get("firstName"); + return (String)attributes.get("email"); + } + + @Override + public String getImageUrl() { + return "readyvery"; + } + + @Override + public String getEmail() { + return Optional.ofNullable((String)attributes.get("email")) + .map(email -> email + "_apple") + .orElse(null); // 여기에서는 null을 반환하지만, 다른 기본값으로 대체할 수도 있습니다. + } + + @Override + public String getPhoneNumber() { + return "readyvery"; + } + + @Override + public String getBirth() { + return "readyvery"; + } + + @Override + public String getAge() { + + return "readyvery"; + } +} diff --git a/src/main/java/com/readyvery/readyverydemo/security/oauth2/userinfo/GoogleOAuth2UserInfo.java b/src/main/java/com/readyvery/readyverydemo/security/oauth2/userinfo/GoogleOAuth2UserInfo.java new file mode 100644 index 0000000..0e2ae90 --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/security/oauth2/userinfo/GoogleOAuth2UserInfo.java @@ -0,0 +1,48 @@ +package com.readyvery.readyverydemo.security.oauth2.userinfo; + +import java.util.Map; +import java.util.Optional; + +public class GoogleOAuth2UserInfo extends OAuth2UserInfo { + + public GoogleOAuth2UserInfo(Map attributes) { + super(attributes); + } + + @Override + public String getId() { + return (String)attributes.get("sub"); + } + + @Override + public String getNickName() { + return (String)attributes.get("name"); + } + + @Override + public String getEmail() { + return Optional.ofNullable((String)attributes.get("email")) + .map(email -> email + "_google") + .orElse(null); // 여기에서는 null을 반환하지만, 다른 기본값으로 대체할 수도 있습니다. + } + + @Override + public String getImageUrl() { + return (String)attributes.get("picture"); + } + + @Override + public String getPhoneNumber() { + return "readyvery"; + } + + @Override + public String getBirth() { + return "readyvery"; + } + + @Override + public String getAge() { + return "readyvery"; + } +} diff --git a/src/main/java/com/readyvery/readyverydemo/security/oauth2/userinfo/KakaoOAuth2UserInfo.java b/src/main/java/com/readyvery/readyverydemo/security/oauth2/userinfo/KakaoOAuth2UserInfo.java index 3ef8d23..c1f956e 100644 --- a/src/main/java/com/readyvery/readyverydemo/security/oauth2/userinfo/KakaoOAuth2UserInfo.java +++ b/src/main/java/com/readyvery/readyverydemo/security/oauth2/userinfo/KakaoOAuth2UserInfo.java @@ -1,6 +1,7 @@ package com.readyvery.readyverydemo.security.oauth2.userinfo; import java.util.Map; +import java.util.Optional; public class KakaoOAuth2UserInfo extends OAuth2UserInfo { @@ -45,7 +46,10 @@ public String getEmail() { return null; } - return (String)account.get("email"); + return Optional.ofNullable((String)account.get("email")) + .map(email -> email + "_kakao") + .orElse(null); // 여기에서는 null을 반환하지만, 다른 기본값으로 대체할 수도 있습니다. + } @Override diff --git a/src/main/java/com/readyvery/readyverydemo/src/board/dto/BoardMapper.java b/src/main/java/com/readyvery/readyverydemo/src/board/dto/BoardMapper.java index 9519131..41a7aac 100644 --- a/src/main/java/com/readyvery/readyverydemo/src/board/dto/BoardMapper.java +++ b/src/main/java/com/readyvery/readyverydemo/src/board/dto/BoardMapper.java @@ -30,7 +30,7 @@ public BoardSearchRes toBoardSearchRes(List stores) { throw new BusinessLogicException(ExceptionCode.STORE_NOT_FOUND); } return BoardSearchRes.builder() - .stores(stores.stream().map(this::toSearchStoreDto).toList()) + .stores(stores.stream().filter(store -> !store.isDeleted()).map(this::toSearchStoreDto).toList()) .build(); } diff --git a/src/main/java/com/readyvery/readyverydemo/src/coupon/CouponServiceImpl.java b/src/main/java/com/readyvery/readyverydemo/src/coupon/CouponServiceImpl.java index 344c126..ef791e8 100644 --- a/src/main/java/com/readyvery/readyverydemo/src/coupon/CouponServiceImpl.java +++ b/src/main/java/com/readyvery/readyverydemo/src/coupon/CouponServiceImpl.java @@ -19,6 +19,7 @@ import com.readyvery.readyverydemo.src.coupon.dto.CouponMapper; import com.readyvery.readyverydemo.src.coupon.dto.CouponsRes; +import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; @Service @@ -36,7 +37,8 @@ public CouponsRes getCoupon(CustomUserDetails userDetails) { } @Override - public synchronized CouponIssueRes issueCoupon(CustomUserDetails userDetails, CouponIssueReq couponIssueReq) { + @Transactional + public CouponIssueRes issueCoupon(CustomUserDetails userDetails, CouponIssueReq couponIssueReq) { CouponDetail couponDetail = getCouponDetail(couponIssueReq); verifyCouponDetail(couponDetail, couponIssueReq); @@ -52,7 +54,9 @@ public synchronized CouponIssueRes issueCoupon(CustomUserDetails userDetails, Co } private Long getIssuedCouponCount(UserInfo userInfo, CouponDetail couponDetail) { - return couponRepository.countByCouponDetailIdAndUserInfoId(couponDetail.getId(), userInfo.getId()); + Coupon coupon = couponRepository.findByUserInfoAndCouponDetail(userInfo, couponDetail) + .orElseThrow(() -> new BusinessLogicException(ExceptionCode.COUPON_NOT_FOUND)); + return coupon.getIssueCount() - coupon.getUseCount(); } private void verifyCouponDetail(CouponDetail couponDetail, CouponIssueReq couponIssueReq) { diff --git a/src/main/java/com/readyvery/readyverydemo/src/coupon/dto/CouponMapper.java b/src/main/java/com/readyvery/readyverydemo/src/coupon/dto/CouponMapper.java index 0dc53ab..c5a512c 100644 --- a/src/main/java/com/readyvery/readyverydemo/src/coupon/dto/CouponMapper.java +++ b/src/main/java/com/readyvery/readyverydemo/src/coupon/dto/CouponMapper.java @@ -15,7 +15,7 @@ public CouponsRes toCouponsRes(List coupons) { return CouponsRes.builder() //filter로 isUsed가 false인 쿠폰만 가져옴 .coupons(coupons.stream() - .filter(coupon -> !coupon.isUsed()) + .filter(coupon -> coupon.getIssueCount() - coupon.getUseCount() > 0) .map(this::toCouponDto) .toList()) .build(); diff --git a/src/main/java/com/readyvery/readyverydemo/src/order/OrderServiceImpl.java b/src/main/java/com/readyvery/readyverydemo/src/order/OrderServiceImpl.java index 725bfc8..c0b4f06 100644 --- a/src/main/java/com/readyvery/readyverydemo/src/order/OrderServiceImpl.java +++ b/src/main/java/com/readyvery/readyverydemo/src/order/OrderServiceImpl.java @@ -29,6 +29,7 @@ import com.readyvery.readyverydemo.domain.FoodieOption; import com.readyvery.readyverydemo.domain.FoodieOptionCategory; import com.readyvery.readyverydemo.domain.Order; +import com.readyvery.readyverydemo.domain.Point; import com.readyvery.readyverydemo.domain.Progress; import com.readyvery.readyverydemo.domain.Receipt; import com.readyvery.readyverydemo.domain.Store; @@ -39,8 +40,8 @@ import com.readyvery.readyverydemo.domain.repository.CouponRepository; import com.readyvery.readyverydemo.domain.repository.FoodieOptionRepository; import com.readyvery.readyverydemo.domain.repository.FoodieRepository; -import com.readyvery.readyverydemo.domain.repository.OrderRepository; import com.readyvery.readyverydemo.domain.repository.OrdersRepository; +import com.readyvery.readyverydemo.domain.repository.PointRepository; import com.readyvery.readyverydemo.domain.repository.ReceiptRepository; import com.readyvery.readyverydemo.domain.repository.StoreRepository; import com.readyvery.readyverydemo.domain.repository.UserRepository; @@ -82,12 +83,12 @@ public class OrderServiceImpl implements OrderService { private final FoodieOptionRepository foodieOptionRepository; private final UserRepository userRepository; private final StoreRepository storeRepository; - private final OrderRepository orderRepository; private final OrderMapper orderMapper; private final TossPaymentConfig tosspaymentConfig; private final OrdersRepository ordersRepository; private final ReceiptRepository receiptRepository; private final CouponRepository couponRepository; + private final PointRepository pointRepository; @Override public FoodyDetailRes getFoody(Long storeId, Long foodyId, Long inout) { @@ -221,19 +222,31 @@ public TosspaymentMakeRes requestTossPayment(CustomUserDetails userDetails, Paym Cart cart = getCartId(user, paymentReq.getCartId()); Store store = cart.getStore(); Coupon coupon = getCoupon(paymentReq.getCouponId()); + Long point = paymentReq.getPoint() != null ? paymentReq.getPoint() : 0L; verifyStoreOpen(store); verifyCoupon(user, coupon); verifyCartSoldOut(cart); + verifyPoint(user, point); + + point *= -1; // 포인트 사용은 음수로 처리 + // Long amount = calculateAmount(store, paymentReq.getCarts(), paymentReq.getInout()); Long amount = calculateAmount2(cart); - Order order = makeOrder(user, store, amount, cart, coupon); + Order order = makeOrder(user, store, amount, cart, coupon, point); cartOrder(cart); - orderRepository.save(order); + ordersRepository.save(order); cartRepository.save(cart); return orderMapper.orderToTosspaymentMakeRes(order); } + private void verifyPoint(UserInfo user, Long point) { + if (user.getPoint() >= point) { + return; + } + throw new BusinessLogicException(ExceptionCode.POINT_NOT_ENOUGH); + } + private void verifyCartSoldOut(Cart cart) { if (cart.getCartItems().stream().anyMatch(cartItem -> cartItem.getFoodie().isSoldOut())) { throw new BusinessLogicException(ExceptionCode.CART_SOLD_OUT); @@ -259,7 +272,7 @@ private void isCoupon(Coupon coupon) { if (coupon == null) { return; } - if (!coupon.isUsed()) { + if (coupon.getIssueCount() - coupon.getUseCount() > 0) { return; } if (coupon.getCouponDetail().getExpire().isAfter(LocalDateTime.now())) { @@ -286,24 +299,42 @@ private void cartOrder(Cart cart) { public PaySuccess tossPaymentSuccess(String paymentKey, String orderId, Long amount) { Order order = getOrder(orderId); verifyOrder(order, amount); - TosspaymentDto tosspaymentDto = requestTossPaymentAccept(paymentKey, orderId, amount); + + TosspaymentDto tosspaymentDto; + if (amount > 0) { + tosspaymentDto = requestTossPaymentAccept(paymentKey, orderId, amount); + } else { // 쿠폰 및 포인트 결제로 0원 결제 시 + tosspaymentDto = makeZeroPaymentDto(paymentKey); + } applyTosspaymentDto(order, tosspaymentDto); - orderRepository.save(order); + ordersRepository.save(order); if (!Objects.equals(order.getMessage(), TOSSPAYMENT_SUCCESS_MESSAGE)) { return orderMapper.tosspaymentDtoToPaySuccess(order.getMessage()); } //TODO: 영수증 처리 Receipt receipt = orderMapper.tosspaymentDtoToReceipt(tosspaymentDto, order); receiptRepository.save(receipt); + // 포인트 처리 + if (order.getPoint() < 0L) { + Point point = orderMapper.orderToPoint(order); + pointRepository.save(point); + } return orderMapper.tosspaymentDtoToPaySuccess(TOSSPAYMENT_SUCCESS_MESSAGE); } + private TosspaymentDto makeZeroPaymentDto(String paymentKey) { + return TosspaymentDto.builder() + .paymentKey(paymentKey) + .method(MEMBERSHIP_PAYMENT_METHOD) + .build(); + } + @Override public FailDto tossPaymentFail(String code, String orderId, String message) { Order order = getOrder(orderId); applyOrderFail(order); - orderRepository.save(order); + ordersRepository.save(order); return orderMapper.makeFailDto(code, message); } @@ -315,6 +346,7 @@ public HistoryRes getHistories(CustomUserDetails userDetails) { } @Override + @Transactional public CurrentRes getCurrent(String orderId) { Order order = getOrder(orderId); verifyOrderCurrent(order); @@ -322,6 +354,7 @@ public CurrentRes getCurrent(String orderId) { } @Override + @Transactional public Object cancelTossPayment(CustomUserDetails userDetails, TossCancelReq tossCancelReq) { UserInfo user = getUserInfo(userDetails); Order order = getOrder(tossCancelReq.getOrderId()); @@ -331,11 +364,12 @@ public Object cancelTossPayment(CustomUserDetails userDetails, TossCancelReq tos applyCancelTosspaymentDto(order, tosspaymentDto); - orderRepository.save(order); + ordersRepository.save(order); return orderMapper.tosspaymentDtoToCancelRes(); } @Override + @Transactional public HistoryDetailRes getReceipt(CustomUserDetails userDetails, String orderId) { UserInfo user = getUserInfo(userDetails); Order order = getOrder(orderId); @@ -387,8 +421,9 @@ private void applyCancelTosspaymentDto(Order order, TosspaymentDto tosspaymentDt order.setPayStatus(false); order.getReceipt().setCancels(tosspaymentDto.getCancels().toString()); order.getReceipt().setStatus(tosspaymentDto.getStatus()); + order.getUserInfo().setPoint(order.getUserInfo().getPoint() - order.getPoint()); if (order.getCoupon() != null) { - order.getCoupon().setUsed(false); + order.getCoupon().setUseCount(order.getCoupon().getUseCount() - 1); } } @@ -464,8 +499,9 @@ private void applyTosspaymentDto(Order order, TosspaymentDto tosspaymentDto) { order.setPayStatus(true); order.getCart().setIsOrdered(true); order.setMessage(TOSSPAYMENT_SUCCESS_MESSAGE); + order.getUserInfo().setPoint(order.getUserInfo().getPoint() + order.getPoint()); if (order.getCoupon() != null) { - order.getCoupon().setUsed(true); + order.getCoupon().setUseCount(order.getCoupon().getUseCount() + 1); } } @@ -525,7 +561,7 @@ private HttpHeaders makeTossHeader() { return headers; } - private Order makeOrder(UserInfo user, Store store, Long amount, Cart cart, Coupon coupon) { + private Order makeOrder(UserInfo user, Store store, Long amount, Cart cart, Coupon coupon, Long point) { List cartItems = cart.getCartItems().stream() .filter(cartItem -> !cartItem.getIsDeleted()) .toList(); @@ -541,14 +577,19 @@ private Order makeOrder(UserInfo user, Store store, Long amount, Cart cart, Coup orderName += " 외 " + (cartItems.stream().filter(cartItem -> !cartItem.getIsDeleted()).count() - 1) + "개"; } + + Long couponAmount = Math.max(0, amount - (coupon != null ? coupon.getCouponDetail().getSalePrice() : 0)); + Long pointAmount = Math.max(0, couponAmount + point); + return Order.builder() .userInfo(user) .store(store) - .amount(amount - (coupon != null ? coupon.getCouponDetail().getSalePrice() : 0)) + .amount(pointAmount) .orderId(UUID.randomUUID().toString()) .cart(cart) .coupon(coupon) .paymentKey(null) + .point(pointAmount - couponAmount) .orderName(orderName) .totalAmount(amount) .orderNumber(null) diff --git a/src/main/java/com/readyvery/readyverydemo/src/order/dto/OrderMapper.java b/src/main/java/com/readyvery/readyverydemo/src/order/dto/OrderMapper.java index 9792cec..48b2b4f 100644 --- a/src/main/java/com/readyvery/readyverydemo/src/order/dto/OrderMapper.java +++ b/src/main/java/com/readyvery/readyverydemo/src/order/dto/OrderMapper.java @@ -19,6 +19,7 @@ import com.readyvery.readyverydemo.domain.FoodieOptionCategory; import com.readyvery.readyverydemo.domain.ImgSize; import com.readyvery.readyverydemo.domain.Order; +import com.readyvery.readyverydemo.domain.Point; import com.readyvery.readyverydemo.domain.Receipt; import com.readyvery.readyverydemo.src.order.config.TossPaymentConfig; @@ -320,10 +321,7 @@ public HistoryDetailRes orderToHistoryDetailRes(Order order) { .orderId(order.getOrderId()) .storePhone(order.getStore().getPhone()) .cart(cartToCartGetRes(order.getCart())) - .salePrice( - order.getCoupon() != null - ? order.getCoupon().getCouponDetail().getSalePrice() - : 0L) + .salePrice(order.getTotalAmount() - order.getAmount()) .method(order.getMethod()) .build(); } @@ -342,4 +340,13 @@ public PaySuccess tosspaymentDtoToPaySuccess(String message) { .message(message) .build(); } + + public Point orderToPoint(Order order) { + return Point.builder() + .order(order) + .userInfo(order.getUserInfo()) + .point(order.getPoint()) + .isDeleted(false) + .build(); + } } diff --git a/src/main/java/com/readyvery/readyverydemo/src/order/dto/PaymentReq.java b/src/main/java/com/readyvery/readyverydemo/src/order/dto/PaymentReq.java index f229b75..d25401a 100644 --- a/src/main/java/com/readyvery/readyverydemo/src/order/dto/PaymentReq.java +++ b/src/main/java/com/readyvery/readyverydemo/src/order/dto/PaymentReq.java @@ -6,4 +6,5 @@ public class PaymentReq { private Long couponId; private Long cartId; + private Long point; } diff --git a/src/main/java/com/readyvery/readyverydemo/src/order/dto/TosspaymentDto.java b/src/main/java/com/readyvery/readyverydemo/src/order/dto/TosspaymentDto.java index 0e56082..6500526 100644 --- a/src/main/java/com/readyvery/readyverydemo/src/order/dto/TosspaymentDto.java +++ b/src/main/java/com/readyvery/readyverydemo/src/order/dto/TosspaymentDto.java @@ -1,8 +1,14 @@ package com.readyvery.readyverydemo.src.order.dto; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; +import lombok.RequiredArgsConstructor; @Getter +@Builder +@AllArgsConstructor +@RequiredArgsConstructor public class TosspaymentDto { private String message; private String paymentKey; diff --git a/src/main/java/com/readyvery/readyverydemo/src/point/PointController.java b/src/main/java/com/readyvery/readyverydemo/src/point/PointController.java new file mode 100644 index 0000000..5974d08 --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/src/point/PointController.java @@ -0,0 +1,33 @@ +package com.readyvery.readyverydemo.src.point; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.readyvery.readyverydemo.security.jwt.dto.CustomUserDetails; +import com.readyvery.readyverydemo.src.point.dto.GetPointHistoryRes; +import com.readyvery.readyverydemo.src.point.dto.GetPointRes; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/point") +public class PointController { + private final PointService pointService; + + @GetMapping("/") + public ResponseEntity getPoint(@AuthenticationPrincipal CustomUserDetails userDetails) { + GetPointRes getPointRes = pointService.getPoint(userDetails); + return new ResponseEntity<>(getPointRes, HttpStatus.OK); + } + + @GetMapping("/history") + public ResponseEntity getPointHistory(@AuthenticationPrincipal CustomUserDetails userDetails) { + GetPointHistoryRes getPointHistoryRes = pointService.getPointHistory(userDetails); + return new ResponseEntity<>(getPointHistoryRes, HttpStatus.OK); + } +} diff --git a/src/main/java/com/readyvery/readyverydemo/src/point/PointService.java b/src/main/java/com/readyvery/readyverydemo/src/point/PointService.java new file mode 100644 index 0000000..de2154f --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/src/point/PointService.java @@ -0,0 +1,11 @@ +package com.readyvery.readyverydemo.src.point; + +import com.readyvery.readyverydemo.security.jwt.dto.CustomUserDetails; +import com.readyvery.readyverydemo.src.point.dto.GetPointHistoryRes; +import com.readyvery.readyverydemo.src.point.dto.GetPointRes; + +public interface PointService { + GetPointRes getPoint(CustomUserDetails userDetails); + + GetPointHistoryRes getPointHistory(CustomUserDetails userDetails); +} diff --git a/src/main/java/com/readyvery/readyverydemo/src/point/PointServiceImpl.java b/src/main/java/com/readyvery/readyverydemo/src/point/PointServiceImpl.java new file mode 100644 index 0000000..1c48e0d --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/src/point/PointServiceImpl.java @@ -0,0 +1,41 @@ +package com.readyvery.readyverydemo.src.point; + +import org.springframework.stereotype.Service; + +import com.readyvery.readyverydemo.domain.UserInfo; +import com.readyvery.readyverydemo.domain.repository.PointRepository; +import com.readyvery.readyverydemo.domain.repository.UserRepository; +import com.readyvery.readyverydemo.global.exception.BusinessLogicException; +import com.readyvery.readyverydemo.global.exception.ExceptionCode; +import com.readyvery.readyverydemo.security.jwt.dto.CustomUserDetails; +import com.readyvery.readyverydemo.src.point.dto.GetPointHistoryRes; +import com.readyvery.readyverydemo.src.point.dto.GetPointRes; +import com.readyvery.readyverydemo.src.point.dto.PointMapper; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class PointServiceImpl implements PointService { + private final UserRepository userRepository; + private final PointRepository pointRepository; + private final PointMapper pointMapper; + + @Override + public GetPointRes getPoint(CustomUserDetails userDetails) { + UserInfo userInfo = getUserInfo(userDetails.getId()); + return pointMapper.toGetPointRes(userInfo.getPoint()); + } + + @Override + public GetPointHistoryRes getPointHistory(CustomUserDetails userDetails) { + UserInfo userInfo = getUserInfo(userDetails.getId()); + return pointMapper.toGetPointHistoryRes(pointRepository.findAllByUserInfo(userInfo)); + } + + private UserInfo getUserInfo(Long id) { + return userRepository.findById(id).orElseThrow( + () -> new BusinessLogicException(ExceptionCode.USER_NOT_FOUND) + ); + } +} diff --git a/src/main/java/com/readyvery/readyverydemo/src/point/dto/GetPointHistoryRes.java b/src/main/java/com/readyvery/readyverydemo/src/point/dto/GetPointHistoryRes.java new file mode 100644 index 0000000..34b0c1a --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/src/point/dto/GetPointHistoryRes.java @@ -0,0 +1,12 @@ +package com.readyvery.readyverydemo.src.point.dto; + +import java.util.List; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class GetPointHistoryRes { + private List history; +} diff --git a/src/main/java/com/readyvery/readyverydemo/src/point/dto/GetPointRes.java b/src/main/java/com/readyvery/readyverydemo/src/point/dto/GetPointRes.java new file mode 100644 index 0000000..50b47e6 --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/src/point/dto/GetPointRes.java @@ -0,0 +1,10 @@ +package com.readyvery.readyverydemo.src.point.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class GetPointRes { + private final Long point; +} diff --git a/src/main/java/com/readyvery/readyverydemo/src/point/dto/PointDto.java b/src/main/java/com/readyvery/readyverydemo/src/point/dto/PointDto.java new file mode 100644 index 0000000..661e1d9 --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/src/point/dto/PointDto.java @@ -0,0 +1,13 @@ +package com.readyvery.readyverydemo.src.point.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class PointDto { + private Boolean status; + private String point; + private String store; + private String date; +} diff --git a/src/main/java/com/readyvery/readyverydemo/src/point/dto/PointMapper.java b/src/main/java/com/readyvery/readyverydemo/src/point/dto/PointMapper.java new file mode 100644 index 0000000..ee00552 --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/src/point/dto/PointMapper.java @@ -0,0 +1,36 @@ +package com.readyvery.readyverydemo.src.point.dto; + +import static com.readyvery.readyverydemo.global.Constant.*; + +import java.time.format.DateTimeFormatter; +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.readyvery.readyverydemo.domain.Point; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class PointMapper { + public GetPointRes toGetPointRes(Long point) { + return GetPointRes.builder().point(point).build(); + } + + public GetPointHistoryRes toGetPointHistoryRes(List points) { + return GetPointHistoryRes + .builder() + .history(points.stream().map(this::pointToPointDto).toList()) + .build(); + } + + private PointDto pointToPointDto(Point point) { + return PointDto.builder() + .point((point.getPoint() > 0 ? "+" : "") + point.getPoint()) // +1 or -1 + .date(point.getCreatedAt().format(DateTimeFormatter.ofPattern(DATE_FORMAT))) + .status(point.getIsDeleted()) + .store(point.getOrder().getStore().getName()) + .build(); + } +} diff --git a/src/main/java/com/readyvery/readyverydemo/src/refreshtoken/RefreshTokenService.java b/src/main/java/com/readyvery/readyverydemo/src/refreshtoken/RefreshTokenService.java new file mode 100644 index 0000000..9f7f43b --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/src/refreshtoken/RefreshTokenService.java @@ -0,0 +1,8 @@ +package com.readyvery.readyverydemo.src.refreshtoken; + +public interface RefreshTokenService { + + void removeRefreshTokenInRedis(String email); + + void saveRefreshTokenInRedis(String email, String refreshToken); +} diff --git a/src/main/java/com/readyvery/readyverydemo/src/refreshtoken/RefreshTokenServiceImpl.java b/src/main/java/com/readyvery/readyverydemo/src/refreshtoken/RefreshTokenServiceImpl.java new file mode 100644 index 0000000..cab58fd --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/src/refreshtoken/RefreshTokenServiceImpl.java @@ -0,0 +1,34 @@ +package com.readyvery.readyverydemo.src.refreshtoken; + +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import com.readyvery.readyverydemo.domain.RefreshToken; +import com.readyvery.readyverydemo.domain.repository.RefreshTokenRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class RefreshTokenServiceImpl implements RefreshTokenService { + + private final RefreshTokenRepository refreshTokenRepository; + + @Override + public void removeRefreshTokenInRedis(String email) { + RefreshToken refreshToken = refreshTokenRepository.findById(email) + .orElseThrow(() -> new UsernameNotFoundException("해당 이메일이 존재하지 않습니다.")); + refreshTokenRepository.delete(refreshToken); + } + + @Override + public void saveRefreshTokenInRedis(String email, String refreshToken) { + RefreshToken token = RefreshToken.builder() + .id(email) + .refreshToken(refreshToken) + .build(); + refreshTokenRepository.save(token); + } +} diff --git a/src/main/java/com/readyvery/readyverydemo/src/smsauthentication/MessageSendingService.java b/src/main/java/com/readyvery/readyverydemo/src/smsauthentication/MessageSendingService.java new file mode 100644 index 0000000..1d55d9d --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/src/smsauthentication/MessageSendingService.java @@ -0,0 +1,35 @@ +package com.readyvery.readyverydemo.src.smsauthentication; + +import org.springframework.stereotype.Service; + +import net.nurigo.sdk.message.exception.NurigoMessageNotReceivedException; +import net.nurigo.sdk.message.model.Message; +import net.nurigo.sdk.message.service.DefaultMessageService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MessageSendingService { + private final DefaultMessageService messageService; + + public boolean sendMessage(String to, String from, String content) { + Message message = new Message(); + message.setTo(to); + message.setFrom(from); + message.setText(content); + + try { + messageService.send(message); + return true; + } catch (NurigoMessageNotReceivedException exception) { + log.error(exception.getFailedMessageList().toString()); + return false; + } catch (Exception exception) { + log.error(exception.getMessage()); + return false; + } + } +} diff --git a/src/main/java/com/readyvery/readyverydemo/src/smsauthentication/SmsController.java b/src/main/java/com/readyvery/readyverydemo/src/smsauthentication/SmsController.java new file mode 100644 index 0000000..fda6f2d --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/src/smsauthentication/SmsController.java @@ -0,0 +1,45 @@ +package com.readyvery.readyverydemo.src.smsauthentication; + +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; + +import com.readyvery.readyverydemo.security.jwt.dto.CustomUserDetails; +import com.readyvery.readyverydemo.src.smsauthentication.dto.SmsRegisterUserPhoneReq; +import com.readyvery.readyverydemo.src.smsauthentication.dto.SmsRegisterUserPhoneRes; +import com.readyvery.readyverydemo.src.smsauthentication.dto.SmsSendReq; +import com.readyvery.readyverydemo.src.smsauthentication.dto.SmsSendRes; +import com.readyvery.readyverydemo.src.smsauthentication.dto.SmsVerifyReq; +import com.readyvery.readyverydemo.src.smsauthentication.dto.SmsVerifyRes; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1") +public class SmsController { + + private final SmsService smsServiceImpl; + + @PostMapping("/sms/send") + public SmsSendRes sendSms( + @RequestBody SmsSendReq smsSendReq, @AuthenticationPrincipal CustomUserDetails userDetails) { + return smsServiceImpl.sendSms(userDetails.getId(), smsSendReq); + } + + @PostMapping("/sms/verify") + public SmsVerifyRes verifySms( + @RequestBody SmsVerifyReq smsVerifyReq, @AuthenticationPrincipal CustomUserDetails userDetails) { + return smsServiceImpl.verifySms(userDetails.getId(), smsVerifyReq); + } + + @PostMapping("/sms/authority") + public SmsRegisterUserPhoneRes authoritySms( + @RequestBody SmsRegisterUserPhoneReq smsRegisterUserPhoneReq, + @AuthenticationPrincipal CustomUserDetails userDetails) { + return smsServiceImpl.authoritySms(userDetails.getId(), smsRegisterUserPhoneReq); + } + +} diff --git a/src/main/java/com/readyvery/readyverydemo/src/smsauthentication/SmsService.java b/src/main/java/com/readyvery/readyverydemo/src/smsauthentication/SmsService.java new file mode 100644 index 0000000..c050ef2 --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/src/smsauthentication/SmsService.java @@ -0,0 +1,17 @@ +package com.readyvery.readyverydemo.src.smsauthentication; + +import com.readyvery.readyverydemo.src.smsauthentication.dto.SmsRegisterUserPhoneReq; +import com.readyvery.readyverydemo.src.smsauthentication.dto.SmsRegisterUserPhoneRes; +import com.readyvery.readyverydemo.src.smsauthentication.dto.SmsSendReq; +import com.readyvery.readyverydemo.src.smsauthentication.dto.SmsSendRes; +import com.readyvery.readyverydemo.src.smsauthentication.dto.SmsVerifyReq; +import com.readyvery.readyverydemo.src.smsauthentication.dto.SmsVerifyRes; + +public interface SmsService { + + SmsSendRes sendSms(Long id, SmsSendReq smsSendReq); + + SmsVerifyRes verifySms(Long id, SmsVerifyReq smsVerifyReq); + + SmsRegisterUserPhoneRes authoritySms(Long id, SmsRegisterUserPhoneReq smsRegisterUserPhoneReq); +} diff --git a/src/main/java/com/readyvery/readyverydemo/src/smsauthentication/SmsServiceImpl.java b/src/main/java/com/readyvery/readyverydemo/src/smsauthentication/SmsServiceImpl.java new file mode 100644 index 0000000..ac8bfb8 --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/src/smsauthentication/SmsServiceImpl.java @@ -0,0 +1,107 @@ +package com.readyvery.readyverydemo.src.smsauthentication; + +import org.springframework.stereotype.Service; + +import com.readyvery.readyverydemo.config.SolApiConfig; +import com.readyvery.readyverydemo.domain.Role; +import com.readyvery.readyverydemo.domain.UserInfo; +import com.readyvery.readyverydemo.global.exception.BusinessLogicException; +import com.readyvery.readyverydemo.global.exception.ExceptionCode; +import com.readyvery.readyverydemo.src.smsauthentication.dto.SmsRegisterUserPhoneReq; +import com.readyvery.readyverydemo.src.smsauthentication.dto.SmsRegisterUserPhoneRes; +import com.readyvery.readyverydemo.src.smsauthentication.dto.SmsSendReq; +import com.readyvery.readyverydemo.src.smsauthentication.dto.SmsSendRes; +import com.readyvery.readyverydemo.src.smsauthentication.dto.SmsVerifyReq; +import com.readyvery.readyverydemo.src.smsauthentication.dto.SmsVerifyRes; +import com.readyvery.readyverydemo.src.user.UserServiceFacade; + +import io.micrometer.common.util.StringUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class SmsServiceImpl implements SmsService { + + private final SolApiConfig solApiConfig; + private final MessageSendingService messageSendingService; + private final VerificationService verificationService; + private final UserServiceFacade userServiceFacade; + + @Override + public SmsSendRes sendSms(Long id, SmsSendReq smsSendReq) { + // Message 패키지가 중복될 경우 net.nurigo.sdk.message.model.Message로 치환하여 주세요 + + duplicateCheck(id, smsSendReq.getPhoneNumber()); + String code = verificationService.createVerificationCode(smsSendReq.getPhoneNumber(), false); + + String messageContent = "[Readyvery] 아래의 인증번호를 입력해주세요.\n인증번호 : " + code; + boolean isMessageSent = messageSendingService.sendMessage(smsSendReq.getPhoneNumber(), + solApiConfig.getPhoneNumber(), messageContent); + + if (isMessageSent) { + return SmsSendRes.builder() + .isSuccess(true) + .smsMessage("인증번호가 발송되었습니다.") + .build(); + } else { + log.error("Message sending failed."); + return SmsSendRes.builder() + .isSuccess(false) + .smsMessage("메시지 발송에 실패하였습니다.") + .build(); + } + } + + @Override + public SmsVerifyRes verifySms(Long id, SmsVerifyReq smsVerifyReq) { + + duplicateCheck(id, smsVerifyReq.getPhoneNumber()); + // Message 패키지가 중복될 경우 net.nurigo.sdk.message.model.Message로 치환하여 주세요 + boolean isValid = verificationService.verifyCode(smsVerifyReq.getPhoneNumber(), smsVerifyReq.getVerifyNumber()); + if (isValid) { + + return SmsVerifyRes.builder() + .isSuccess(true) + .smsMessage("인증에 성공하였습니다.") + .build(); + } else { + return SmsVerifyRes.builder() + .isSuccess(false) + .smsMessage("인증에 실패하였습니다.") + .build(); + } + + } + + @Override + public SmsRegisterUserPhoneRes authoritySms(Long id, SmsRegisterUserPhoneReq smsRegisterUserPhoneReq) { + + UserInfo userInfo = duplicateCheck(id, smsRegisterUserPhoneReq.getPhoneNumber()); + if (verificationService.verifyNumber(smsRegisterUserPhoneReq.getPhoneNumber())) { + userServiceFacade.updateUserPhone(userInfo, smsRegisterUserPhoneReq.getPhoneNumber()); + return SmsRegisterUserPhoneRes.builder() + .isSuccess(true) + .smsMessage("전화번호 등록을 완료하였습니다.") + .build(); + } else { + return SmsRegisterUserPhoneRes.builder() + .isSuccess(false) + .smsMessage("전화번호 등록을 실패하였습니다.") + .build(); + } + } + + public UserInfo duplicateCheck(Long id, String phoneNumber) { + if (StringUtils.isEmpty(phoneNumber)) { + throw new BusinessLogicException(ExceptionCode.INVALID_INPUT); + } + UserInfo userInfo = userServiceFacade.getUserInfo(id); + if (userInfo.getRole() == null || userInfo.getRole().equals(Role.USER)) { + throw new BusinessLogicException(ExceptionCode.UNAUTHORIZED); + } + return userInfo; + } + +} diff --git a/src/main/java/com/readyvery/readyverydemo/src/smsauthentication/VerificationService.java b/src/main/java/com/readyvery/readyverydemo/src/smsauthentication/VerificationService.java new file mode 100644 index 0000000..619c3f4 --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/src/smsauthentication/VerificationService.java @@ -0,0 +1,42 @@ +package com.readyvery.readyverydemo.src.smsauthentication; + +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class VerificationService { + + private final RedisTemplate redisTemplate; + + public String createVerificationCode(String phoneNumber, boolean someBooleanValue) { + String code = UUID.randomUUID().toString().substring(0, 6); + redisTemplate.opsForValue().set(phoneNumber + ":code", code, 5, TimeUnit.MINUTES); + redisTemplate.opsForValue().set(phoneNumber + ":flag", String.valueOf(someBooleanValue), 5, TimeUnit.MINUTES); + return code; + } + + public boolean verifyCode(String phoneNumber, String code) { + String storedCodeKey = phoneNumber + ":code"; + String flagKey = phoneNumber + ":flag"; + + String storedCode = redisTemplate.opsForValue().get(storedCodeKey); + if (storedCode != null && storedCode.equals(code)) { + // 인증 코드가 일치하면 플래그 값을 true로 설정 + redisTemplate.opsForValue().set(flagKey, "true", 5, TimeUnit.MINUTES); + return true; + } else { + return false; + } + } + + public boolean verifyNumber(String phoneNumber) { + String storedFlag = redisTemplate.opsForValue().get(phoneNumber + ":flag"); + return Boolean.parseBoolean(storedFlag); + } +} diff --git a/src/main/java/com/readyvery/readyverydemo/src/smsauthentication/dto/SmsRegisterUserPhoneReq.java b/src/main/java/com/readyvery/readyverydemo/src/smsauthentication/dto/SmsRegisterUserPhoneReq.java new file mode 100644 index 0000000..3bf80c6 --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/src/smsauthentication/dto/SmsRegisterUserPhoneReq.java @@ -0,0 +1,12 @@ +package com.readyvery.readyverydemo.src.smsauthentication.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class SmsRegisterUserPhoneReq { + private String phoneNumber; +} diff --git a/src/main/java/com/readyvery/readyverydemo/src/smsauthentication/dto/SmsRegisterUserPhoneRes.java b/src/main/java/com/readyvery/readyverydemo/src/smsauthentication/dto/SmsRegisterUserPhoneRes.java new file mode 100644 index 0000000..e916706 --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/src/smsauthentication/dto/SmsRegisterUserPhoneRes.java @@ -0,0 +1,11 @@ +package com.readyvery.readyverydemo.src.smsauthentication.dto; + +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +public class SmsRegisterUserPhoneRes { + private boolean isSuccess; + private String smsMessage; +} diff --git a/src/main/java/com/readyvery/readyverydemo/src/smsauthentication/dto/SmsSendReq.java b/src/main/java/com/readyvery/readyverydemo/src/smsauthentication/dto/SmsSendReq.java new file mode 100644 index 0000000..e3bbcc0 --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/src/smsauthentication/dto/SmsSendReq.java @@ -0,0 +1,14 @@ +package com.readyvery.readyverydemo.src.smsauthentication.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class SmsSendReq { + + private String phoneNumber; + +} diff --git a/src/main/java/com/readyvery/readyverydemo/src/smsauthentication/dto/SmsSendRes.java b/src/main/java/com/readyvery/readyverydemo/src/smsauthentication/dto/SmsSendRes.java new file mode 100644 index 0000000..87075ba --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/src/smsauthentication/dto/SmsSendRes.java @@ -0,0 +1,12 @@ +package com.readyvery.readyverydemo.src.smsauthentication.dto; + +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +public class SmsSendRes { + + private boolean isSuccess; + private String smsMessage; +} diff --git a/src/main/java/com/readyvery/readyverydemo/src/smsauthentication/dto/SmsVerifyReq.java b/src/main/java/com/readyvery/readyverydemo/src/smsauthentication/dto/SmsVerifyReq.java new file mode 100644 index 0000000..770cb9a --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/src/smsauthentication/dto/SmsVerifyReq.java @@ -0,0 +1,14 @@ +package com.readyvery.readyverydemo.src.smsauthentication.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class SmsVerifyReq { + + private String phoneNumber; + private String verifyNumber; +} diff --git a/src/main/java/com/readyvery/readyverydemo/src/smsauthentication/dto/SmsVerifyRes.java b/src/main/java/com/readyvery/readyverydemo/src/smsauthentication/dto/SmsVerifyRes.java new file mode 100644 index 0000000..78c78d0 --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/src/smsauthentication/dto/SmsVerifyRes.java @@ -0,0 +1,12 @@ +package com.readyvery.readyverydemo.src.smsauthentication.dto; + +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +public class SmsVerifyRes { + + private boolean isSuccess; + private String smsMessage; +} diff --git a/src/main/java/com/readyvery/readyverydemo/src/store/StoreServiceFacade.java b/src/main/java/com/readyvery/readyverydemo/src/store/StoreServiceFacade.java new file mode 100644 index 0000000..02f3977 --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/src/store/StoreServiceFacade.java @@ -0,0 +1,21 @@ +package com.readyvery.readyverydemo.src.store; + +import org.springframework.stereotype.Service; + +import com.readyvery.readyverydemo.domain.Store; +import com.readyvery.readyverydemo.domain.repository.StoreRepository; +import com.readyvery.readyverydemo.global.exception.BusinessLogicException; +import com.readyvery.readyverydemo.global.exception.ExceptionCode; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class StoreServiceFacade { + private final StoreRepository storeRepository; + + public Store getStoreById(Long storeId) { + return storeRepository.findById(storeId) + .orElseThrow(() -> new BusinessLogicException(ExceptionCode.STORE_NOT_FOUND)); + } +} diff --git a/src/main/java/com/readyvery/readyverydemo/src/store/StoreServiceImpl.java b/src/main/java/com/readyvery/readyverydemo/src/store/StoreServiceImpl.java index b12288b..211c97f 100644 --- a/src/main/java/com/readyvery/readyverydemo/src/store/StoreServiceImpl.java +++ b/src/main/java/com/readyvery/readyverydemo/src/store/StoreServiceImpl.java @@ -3,9 +3,6 @@ import org.springframework.stereotype.Service; import com.readyvery.readyverydemo.domain.Store; -import com.readyvery.readyverydemo.domain.repository.StoreRepository; -import com.readyvery.readyverydemo.global.exception.BusinessLogicException; -import com.readyvery.readyverydemo.global.exception.ExceptionCode; import com.readyvery.readyverydemo.src.store.dto.StoreDetailRes; import com.readyvery.readyverydemo.src.store.dto.StoreEventRes; import com.readyvery.readyverydemo.src.store.dto.StoreMapper; @@ -16,29 +13,24 @@ @Service @RequiredArgsConstructor public class StoreServiceImpl implements StoreService { - private final StoreRepository storeRepository; + private final StoreServiceFacade storeServiceFacade; private final StoreMapper storeMapper; @Override public StoreDetailRes getStoreDetail(Long storeId) { - Store store = getStore(storeId); + Store store = storeServiceFacade.getStoreById(storeId); return storeMapper.storeToStoreDetailRes(store); } - private Store getStore(Long storeId) { - return storeRepository.findById(storeId) - .orElseThrow(() -> new BusinessLogicException(ExceptionCode.STORE_NOT_FOUND)); - } - @Override public StoreMenuRes getStoreMenu(Long storeId) { - Store store = getStore(storeId); + Store store = storeServiceFacade.getStoreById(storeId); return storeMapper.storeToStoreMenuRes(store); } @Override public StoreEventRes getStoreEvent(Long storeId) { - Store store = getStore(storeId); + Store store = storeServiceFacade.getStoreById(storeId); return storeMapper.storeToStoreEventRes(store); } diff --git a/src/main/java/com/readyvery/readyverydemo/src/store/dto/StoreDetailRes.java b/src/main/java/com/readyvery/readyverydemo/src/store/dto/StoreDetailRes.java index e089956..adefe87 100644 --- a/src/main/java/com/readyvery/readyverydemo/src/store/dto/StoreDetailRes.java +++ b/src/main/java/com/readyvery/readyverydemo/src/store/dto/StoreDetailRes.java @@ -14,4 +14,5 @@ public class StoreDetailRes { private String address; private String openTime; private Boolean status; + private String eventMessage; } diff --git a/src/main/java/com/readyvery/readyverydemo/src/store/dto/StoreMapper.java b/src/main/java/com/readyvery/readyverydemo/src/store/dto/StoreMapper.java index c7e9e78..d03471a 100644 --- a/src/main/java/com/readyvery/readyverydemo/src/store/dto/StoreMapper.java +++ b/src/main/java/com/readyvery/readyverydemo/src/store/dto/StoreMapper.java @@ -25,6 +25,7 @@ public StoreDetailRes storeToStoreDetailRes(Store store) { .phone(store.getPhone()) .address(store.getAddress()) .openTime(store.getTime()) + .eventMessage(store.getEventMessage()) .build(); } diff --git a/src/main/java/com/readyvery/readyverydemo/src/user/UserController.java b/src/main/java/com/readyvery/readyverydemo/src/user/UserController.java index f233934..9b0cb83 100644 --- a/src/main/java/com/readyvery/readyverydemo/src/user/UserController.java +++ b/src/main/java/com/readyvery/readyverydemo/src/user/UserController.java @@ -66,7 +66,7 @@ public CustomUserDetails userDetail(@AuthenticationPrincipal CustomUserDetails u @GetMapping("/user/logout") public UserLogoutRes logout(@AuthenticationPrincipal CustomUserDetails userDetails, HttpServletResponse response) { - userServiceImpl.removeRefreshTokenInDB(userDetails.getId(), response); + userServiceImpl.removeRefreshTokenInDB(userDetails.getEmail(), response); return UserLogoutRes.builder() .success(true) .message("로그아웃 성공") diff --git a/src/main/java/com/readyvery/readyverydemo/src/user/UserService.java b/src/main/java/com/readyvery/readyverydemo/src/user/UserService.java index c80b2fc..0bd65fc 100644 --- a/src/main/java/com/readyvery/readyverydemo/src/user/UserService.java +++ b/src/main/java/com/readyvery/readyverydemo/src/user/UserService.java @@ -14,7 +14,7 @@ public interface UserService { UserInfoRes getUserInfoById(Long id); - void removeRefreshTokenInDB(Long id, HttpServletResponse response); + void removeRefreshTokenInDB(String email, HttpServletResponse response); UserRemoveRes removeUser(Long id, HttpServletResponse response) throws IOException; diff --git a/src/main/java/com/readyvery/readyverydemo/src/user/UserServiceFacade.java b/src/main/java/com/readyvery/readyverydemo/src/user/UserServiceFacade.java new file mode 100644 index 0000000..8efbec3 --- /dev/null +++ b/src/main/java/com/readyvery/readyverydemo/src/user/UserServiceFacade.java @@ -0,0 +1,35 @@ +package com.readyvery.readyverydemo.src.user; + +import org.springframework.stereotype.Service; + +import com.readyvery.readyverydemo.domain.UserInfo; +import com.readyvery.readyverydemo.domain.repository.UserRepository; +import com.readyvery.readyverydemo.global.exception.BusinessLogicException; +import com.readyvery.readyverydemo.global.exception.ExceptionCode; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class UserServiceFacade { + + private final UserRepository userRepository; + + public UserInfo getUserInfo(Long id) { + return userRepository.findById(id).orElseThrow( + () -> new BusinessLogicException(ExceptionCode.USER_NOT_FOUND) + ); + } + + public UserInfo getUserInfoByEmail(String email) { + return userRepository.findByEmail(email).orElseThrow( + () -> new BusinessLogicException(ExceptionCode.USER_NOT_FOUND) + ); + } + + public void updateUserPhone(UserInfo userInfo, String phoneNumber) { + + userInfo.updatePhone(phoneNumber); + userRepository.save(userInfo); + } +} diff --git a/src/main/java/com/readyvery/readyverydemo/src/user/UserServiceImpl.java b/src/main/java/com/readyvery/readyverydemo/src/user/UserServiceImpl.java index b81ca4c..d726492 100644 --- a/src/main/java/com/readyvery/readyverydemo/src/user/UserServiceImpl.java +++ b/src/main/java/com/readyvery/readyverydemo/src/user/UserServiceImpl.java @@ -1,5 +1,7 @@ package com.readyvery.readyverydemo.src.user; +import static com.readyvery.readyverydemo.config.JwtConfig.*; + import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; @@ -8,15 +10,17 @@ import java.net.URL; import java.nio.charset.StandardCharsets; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.readyvery.readyverydemo.config.UserApiConfig; +import com.readyvery.readyverydemo.domain.SocialType; import com.readyvery.readyverydemo.domain.UserInfo; import com.readyvery.readyverydemo.domain.repository.UserRepository; import com.readyvery.readyverydemo.global.exception.BusinessLogicException; import com.readyvery.readyverydemo.global.exception.ExceptionCode; import com.readyvery.readyverydemo.security.jwt.dto.CustomUserDetails; +import com.readyvery.readyverydemo.src.refreshtoken.RefreshTokenService; import com.readyvery.readyverydemo.src.user.dto.UserAuthRes; import com.readyvery.readyverydemo.src.user.dto.UserInfoRes; import com.readyvery.readyverydemo.src.user.dto.UserMapper; @@ -35,53 +39,48 @@ public class UserServiceImpl implements UserService { private final UserRepository userRepository; private final UserMapper userMapper; - @Value("${jwt.refresh.cookie}") - private String refreshCookie; - @Value("${jwt.access.cookie}") - private String accessToken; - @Value("${service.app.admin.key}") - private String serviceAppAdminKey; - @Value("${jwt.access.cookie.domain}") - private String accessCookieDomain; - @Value("${jwt.refresh.cookie.domain}") - private String refreshCookieDomain; + private final UserApiConfig userApiConfig; + private final RefreshTokenService refreshTokenServiceImpl; + private final UserServiceFacade userServiceFacade; @Override public UserAuthRes getUserAuthByCustomUserDetails(CustomUserDetails userDetails) { verifyUserDetails(userDetails); - return userMapper.userInfoToUserAuthRes(userDetails); + UserInfo userInfo = userServiceFacade.getUserInfo(userDetails.getId()); + return userMapper.userInfoToUserAuthRes(userDetails.isEnabled(), userInfo); } private void verifyUserDetails(CustomUserDetails userDetails) { if (userDetails == null) { - throw new BusinessLogicException(ExceptionCode.USER_NOT_FOUND); + throw new BusinessLogicException(ExceptionCode.AUTH_ERROR); } } @Override public UserInfoRes getUserInfoById(Long id) { - UserInfo userInfo = getUserInfo(id); + UserInfo userInfo = userServiceFacade.getUserInfo(id); return userMapper.userInfoToUserInfoRes(userInfo); } @Override - public void removeRefreshTokenInDB(Long id, HttpServletResponse response) { - UserInfo user = getUserInfo(id); - user.updateRefresh(null); // Refresh Token을 null 또는 빈 문자열로 업데이트 - userRepository.save(user); + public void removeRefreshTokenInDB(String email, HttpServletResponse response) { + refreshTokenServiceImpl.removeRefreshTokenInRedis(email); // Redis에서 Refresh Token 삭제 invalidateRefreshTokenCookie(response); // 쿠키 무효화 } @Override public UserRemoveRes removeUser(Long id, HttpServletResponse response) throws IOException { - UserInfo user = getUserInfo(id); - String kakaoRes = requestToServer("https://kapi.kakao.com/v1/user/unlink", "KakaoAK " + serviceAppAdminKey, - "target_id_type=user_id&target_id=" + user.getSocialId()); - user.updateRemoveUserDate(); - user.updateRefresh(null); // Refresh Token을 null 또는 빈 문자열로 업데이트 + UserInfo user = userServiceFacade.getUserInfo(id); + if (user.getSocialType().equals(SocialType.KAKAO)) { + requestToServer( + "KakaoAK " + userApiConfig.getServiceAppAdminKey(), + "target_id_type=user_id&target_id=" + user.getSocialId()); + } + + removeRefreshTokenInDB(user.getEmail(), response); // Refresh Token 삭제 + user.updateRemoveUserDate(); // 회원 탈퇴 날짜 업데이트 userRepository.save(user); - invalidateRefreshTokenCookie(response); // 쿠키 무효화 return UserRemoveRes.builder() .message("회원 탈퇴가 완료되었습니다.") .success(true) @@ -93,28 +92,16 @@ public UserRemoveRes removeUser(Long id, HttpServletResponse response) throws IO * @param response */ private void invalidateRefreshTokenCookie(HttpServletResponse response) { - Cookie refreshTokenCookie = new Cookie(refreshCookie, null); // 쿠키 이름을 동일하게 설정 + Cookie refreshTokenCookie = new Cookie(userApiConfig.getRefreshCookie(), null); // 쿠키 이름을 동일하게 설정 refreshTokenCookie.setHttpOnly(true); refreshTokenCookie.setPath("/api/v1/refresh/token"); // 기존과 동일한 경로 설정 - refreshTokenCookie.setDomain(refreshCookieDomain); refreshTokenCookie.setMaxAge(0); // 만료 시간을 0으로 설정하여 즉시 만료 response.addCookie(refreshTokenCookie); - Cookie accessTokenCookie = new Cookie(accessToken, null); // 쿠키 이름을 동일하게 설정 - accessTokenCookie.setPath("/"); // 기존과 동일한 경로 설정 - accessTokenCookie.setDomain(accessCookieDomain); - accessTokenCookie.setMaxAge(0); // 만료 시간을 0으로 설정하여 즉시 만료 - response.addCookie(accessTokenCookie); - - } - private UserInfo getUserInfo(Long id) { - return userRepository.findById(id).orElseThrow( - () -> new BusinessLogicException(ExceptionCode.USER_NOT_FOUND) - ); } - private String requestToServer(String kakaoApiurl, String headerStr, String postData) throws IOException { - URL url = new URL(kakaoApiurl); + private String requestToServer(String headerStr, String postData) throws IOException { + URL url = new URL("https://kapi.kakao.com/v1/user/unlink"); HttpURLConnection connectReq = null; try { @@ -125,7 +112,7 @@ private String requestToServer(String kakaoApiurl, String headerStr, String post // Set headers connectReq.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); if (headerStr != null && !headerStr.isEmpty()) { - connectReq.setRequestProperty("Authorization", headerStr); + connectReq.setRequestProperty(AUTHORIZATION, headerStr); } // Write the post data to the request body diff --git a/src/main/java/com/readyvery/readyverydemo/src/user/dto/UserAuthRes.java b/src/main/java/com/readyvery/readyverydemo/src/user/dto/UserAuthRes.java index b6b1049..eb26186 100644 --- a/src/main/java/com/readyvery/readyverydemo/src/user/dto/UserAuthRes.java +++ b/src/main/java/com/readyvery/readyverydemo/src/user/dto/UserAuthRes.java @@ -1,5 +1,7 @@ package com.readyvery.readyverydemo.src.user.dto; +import com.readyvery.readyverydemo.domain.Role; + import lombok.Builder; import lombok.Getter; @@ -9,6 +11,6 @@ public class UserAuthRes { private Long id; private String email; private boolean auth; - private boolean admin; + private Role role; } diff --git a/src/main/java/com/readyvery/readyverydemo/src/user/dto/UserMapper.java b/src/main/java/com/readyvery/readyverydemo/src/user/dto/UserMapper.java index c054106..ab46ffa 100644 --- a/src/main/java/com/readyvery/readyverydemo/src/user/dto/UserMapper.java +++ b/src/main/java/com/readyvery/readyverydemo/src/user/dto/UserMapper.java @@ -3,16 +3,15 @@ import org.springframework.stereotype.Component; import com.readyvery.readyverydemo.domain.UserInfo; -import com.readyvery.readyverydemo.security.jwt.dto.CustomUserDetails; @Component public class UserMapper { - public UserAuthRes userInfoToUserAuthRes(CustomUserDetails userDetails) { + public UserAuthRes userInfoToUserAuthRes(boolean auth, UserInfo userInfo) { return UserAuthRes.builder() - .id(userDetails.getId()) - .email(userDetails.getEmail()) - .auth(userDetails.isEnabled()) - .admin(false) + .id(userInfo.getId()) + .email(userInfo.getEmail()) + .auth(auth) + .role(userInfo.getRole()) .build(); } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 9302dd8..9110719 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -34,5 +34,5 @@ spring.security.oauth2.client.provider.kakao.user-name-attribute=id ##tosspay payment.toss.client_key=test_ck_pP2YxJ4K87By0b4RZeo0rRGZwXLO payment.toss.secret_key=test_sk_pP2YxJ4K87BbBaQPDwEJrRGZwXLO -payment.toss.success_url=http://localhost:3000/payment/success +payment.toss.success_url=http://localhost:3000/payment/loading payment.toss.fail_url=http://localhost:3000/payment/fail diff --git a/src/test/java/com/readyvery/readyverydemo/solapi/SmsServiceImplTest.java b/src/test/java/com/readyvery/readyverydemo/solapi/SmsServiceImplTest.java new file mode 100644 index 0000000..88c2c0e --- /dev/null +++ b/src/test/java/com/readyvery/readyverydemo/solapi/SmsServiceImplTest.java @@ -0,0 +1,144 @@ +package com.readyvery.readyverydemo.solapi; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +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 com.readyvery.readyverydemo.config.SolApiConfig; +import com.readyvery.readyverydemo.domain.Role; +import com.readyvery.readyverydemo.domain.UserInfo; +import com.readyvery.readyverydemo.src.smsauthentication.MessageSendingService; +import com.readyvery.readyverydemo.src.smsauthentication.SmsServiceImpl; +import com.readyvery.readyverydemo.src.smsauthentication.VerificationService; +import com.readyvery.readyverydemo.src.smsauthentication.dto.SmsRegisterUserPhoneReq; +import com.readyvery.readyverydemo.src.smsauthentication.dto.SmsRegisterUserPhoneRes; +import com.readyvery.readyverydemo.src.smsauthentication.dto.SmsSendReq; +import com.readyvery.readyverydemo.src.smsauthentication.dto.SmsSendRes; +import com.readyvery.readyverydemo.src.smsauthentication.dto.SmsVerifyReq; +import com.readyvery.readyverydemo.src.smsauthentication.dto.SmsVerifyRes; +import com.readyvery.readyverydemo.src.user.UserServiceFacade; + +@ExtendWith(MockitoExtension.class) +public class SmsServiceImplTest { + + @Mock + private SolApiConfig solApiConfig; + + @Mock + private VerificationService verificationService; + + @InjectMocks + private SmsServiceImpl smsService; + + @Mock + private UserServiceFacade userServiceFacade; + + @Mock + private MessageSendingService messageSendingService; // MessageSendingService에 대한 모킹 추가 + + @BeforeEach + void setUp() { + // 필요한 경우 초기화 코드 작성 + UserInfo mockUserInfo = mock(UserInfo.class); + lenient().when(mockUserInfo.getRole()).thenReturn(Role.GUEST); + lenient().when(userServiceFacade.getUserInfo(anyLong())).thenReturn(mockUserInfo); + } + + @Test + void testSendSms() { + // given + SmsSendReq request = new SmsSendReq("01064393547"); + when(solApiConfig.getPhoneNumber()).thenReturn("01064393547"); + when(verificationService.createVerificationCode("01064393547", false)).thenReturn("123456"); + when(messageSendingService.sendMessage(request.getPhoneNumber(), solApiConfig.getPhoneNumber(), + "[Readyvery] 아래의 인증번호를 입력해주세요.\n인증번호 : 123456")).thenReturn( + true); // sendMessage의 결과를 모킹 + + // when + SmsSendRes response = smsService.sendSms(1L, request); + + // then + assertTrue(response.isSuccess()); + assertEquals("인증번호가 발송되었습니다.", response.getSmsMessage()); + } + + @Test + void testVerifySms_Success() { + // given + SmsVerifyReq request = new SmsVerifyReq("01012345678", "123456"); + when(verificationService.verifyCode(request.getPhoneNumber(), request.getVerifyNumber())).thenReturn(true); + //doNothing().when(ceoServiceImpl).changeRoleAndSave(1L, Role.USER); + + // when + SmsVerifyRes response = smsService.verifySms(1L, request); + + // then + assertTrue(response.isSuccess()); + assertEquals("인증에 성공하였습니다.", response.getSmsMessage()); + } + + @Test + void testVerifySms_Failure() { + // given + SmsVerifyReq request = new SmsVerifyReq("01012345678", "123456"); + when(verificationService.verifyCode(request.getPhoneNumber(), request.getVerifyNumber())).thenReturn(false); + + // when + SmsVerifyRes response = smsService.verifySms(1L, request); + + // then + assertFalse(response.isSuccess()); + assertEquals("인증에 실패하였습니다.", response.getSmsMessage()); + } + + @Test + void testVerifyNumber() { + // given + String phoneNumber = "01012345678"; + when(verificationService.verifyNumber(phoneNumber)).thenReturn(true); + + // when + boolean result = verificationService.verifyNumber(phoneNumber); + + // then + assertTrue(result); + } + + @Test + void testAuthoritySms_Success() { + // given + SmsRegisterUserPhoneReq request = new SmsRegisterUserPhoneReq("01012345678"); + + // 올바른 반환 타입 설정: Optional.of(mockUserInfo) + when(verificationService.verifyNumber(request.getPhoneNumber())).thenReturn(true); + + // when + SmsRegisterUserPhoneRes response = smsService.authoritySms(1L, request); + + // then + assertTrue(response.isSuccess()); + assertEquals("전화번호 등록을 완료하였습니다.", response.getSmsMessage()); + } + + @Test + void testAuthoritySms_Failure() { + // given + SmsRegisterUserPhoneReq request = new SmsRegisterUserPhoneReq("01012345678"); + + // 올바른 반환 타입 설정: Optional.of(mockUserInfo) + when(verificationService.verifyNumber(request.getPhoneNumber())).thenReturn(false); + + // when + SmsRegisterUserPhoneRes response = smsService.authoritySms(1L, request); + + // then + assertFalse(response.isSuccess()); + assertEquals("전화번호 등록을 실패하였습니다.", response.getSmsMessage()); + } +}