diff --git a/.github/workflows/dev-build-and-deploy.yaml b/.github/workflows/dev-build-and-deploy.yaml index d5cd33e5..45d4ccd2 100644 --- a/.github/workflows/dev-build-and-deploy.yaml +++ b/.github/workflows/dev-build-and-deploy.yaml @@ -120,10 +120,20 @@ jobs: -e OAUTH_CLIENTID=${{ secrets.KAKAO_CLIENT_ID }} \ -e OAUTH_KAUTHTOKENURLHOST=${{ secrets.KAUTH_TOKEN_URL_HOST }} \ -e OAUTH_KAUTHUSERURLHOST=${{ secrets.KAUTH_USER_URL_HOST }} \ + -e OAUTH_KAKAOCLIENTID=${{ secrets.OAUTH_KAKAOCLIENTID }} \ + -e OAUTH_KAKAOAUTHTOKENURLHOST=${{ secrets.KAKAOAUTHTOKENURLHOST }} \ + -e OAUTH_KAKAOAUTHUSERURLHOST=${{ secrets.KAKAOAUTHUSERURLHOST }} \ + -e OAUTH_KAKAOREDIRECTURL=${{ secrets.KAKAOREDIRECTURL }} \ + -e OAUTH_GOOGLECLIENTID=${{ secrets.GOOGLECLIENTID }} \ + -e OAUTH_GOOGLECLIENTSECRET=${{ secrets.GOOGLECLIENTSECRET }} \ + -e OAUTH_GOOGLEREDIRECTURL=${{ secrets.GOOGLEREDIRECTURL }} \ + -e OAUTH_GOOGLEAUTHTOKENURLHOST=${{ secrets.GOOGLEAUTHTOKENURLHOST }} \ + -e OAUTH_GOOGLEUSERURLHOST=${{ secrets.GOOGLEUSERURLHOST }} \ -e SPRING_JPA_HIBERNATE_DDL_AUTO=validate \ -e AWS_S3_ACCESS_KEY=${{ secrets.AWS_S3_ACCESS_KEY }} \ -e AWS_S3_SECRET_KEY=${{ secrets.AWS_S3_SECRET_KEY }} \ -e AWS_S3_BUCKET_NAME=${{ secrets.DEV_AWS_S3_BUCKET_NAME }} \ + -e AWS_S3_BASICPROFILEIMAGEURL=${{ secrets.BASICPROFILEIMAGEURL }} \ -e TZ=Asia/Seoul \ -e SENTRY_DSN=${{ secrets.SENTRY_DSN }} \ -e SENTRY_ENABLE_TRACING=true \ diff --git a/.github/workflows/manual-prod-deploy.yaml b/.github/workflows/manual-prod-deploy.yaml index 945007c7..ec62a71a 100644 --- a/.github/workflows/manual-prod-deploy.yaml +++ b/.github/workflows/manual-prod-deploy.yaml @@ -54,13 +54,23 @@ jobs: -e SPRING_DATASOURCE_USERNAME=${{ secrets.PROD_DB_USERNAME }} \ -e SPRING_DATASOURCE_PASSWORD=${{ secrets.PROD_DB_PASSWORD }} \ -e SPRING_JWT_SECRET=${{ secrets.JWT_SECRET }} \ - -e OAUTH_CLIENTID=${{ secrets.KAKAO_CLIENT_ID }} \ + -e KAKAO_CLIENT_ID=${{ secrets.KAKAO_CLIENT_ID }} \ -e OAUTH_KAUTHTOKENURLHOST=${{ secrets.KAUTH_TOKEN_URL_HOST }} \ -e OAUTH_KAUTHUSERURLHOST=${{ secrets.KAUTH_USER_URL_HOST }} \ + -e OAUTH_KAKAOCLIENTID=${{ secrets.OAUTH_KAKAOCLIENTID }} \ + -e OAUTH_KAKAOAUTHTOKENURLHOST=${{ secrets.KAKAOAUTHTOKENURLHOST }} \ + -e OAUTH_KAKAOAUTHUSERURLHOST=${{ secrets.KAKAOAUTHUSERURLHOST }} \ + -e OAUTH_KAKAOREDIRECTURL=${{ secrets.KAKAOREDIRECTURL }} \ + -e OAUTH_GOOGLECLIENTID=${{ secrets.GOOGLECLIENTID }} \ + -e OAUTH_GOOGLECLIENTSECRET=${{ secrets.GOOGLECLIENTSECRET }} \ + -e OAUTH_GOOGLEREDIRECTURL=${{ secrets.GOOGLEREDIRECTURL }} \ + -e OAUTH_GOOGLEAUTHTOKENURLHOST=${{ secrets.GOOGLEAUTHTOKENURLHOST }} \ + -e OAUTH_GOOGLEUSERURLHOST=${{ secrets.GOOGLEUSERURLHOST }} \ -e SPRING_JPA_HIBERNATE_DDL_AUTO=validate \ -e AWS_S3_ACCESS_KEY=${{ secrets.AWS_S3_ACCESS_KEY }} \ -e AWS_S3_SECRET_KEY=${{ secrets.AWS_S3_SECRET_KEY }} \ -e AWS_S3_BUCKET_NAME=${{ secrets.PROD_AWS_S3_BUCKET_NAME }} \ + -e AWS_S3_BASICPROFILEIMAGEURL=${{ secrets.BASICPROFILEIMAGEURL }} \ -e TZ=Asia/Seoul \ -e SENTRY_DSN=${{ secrets.SENTRY_DSN }} \ -e SENTRY_ENABLE_TRACING=true \ diff --git a/.gitignore b/.gitignore index b28ad111..008d7ed9 100644 --- a/.gitignore +++ b/.gitignore @@ -387,7 +387,10 @@ gradle-app.setting *.application-jwt.yml *.application-monitoring.yml application-jwt.yml -application-kakao.yml +application-oauth.yml application-sentry.yml application-aws.yaml -application-mixpanel.yaml \ No newline at end of file +application-mixpanel.yaml + +# 민성 레디스 바이너리 파일 +redis-server-7.2.3-mac-arm64 \ No newline at end of file diff --git a/application/build.gradle.kts b/application/build.gradle.kts index cee45e8f..b06e9278 100644 --- a/application/build.gradle.kts +++ b/application/build.gradle.kts @@ -35,6 +35,12 @@ dependencies { // Mixpanel implementation("com.mixpanel:mixpanel-java:_") + // test container + testImplementation("org.testcontainers:testcontainers:_") + testImplementation("org.testcontainers:junit-jupiter:_") + testImplementation("org.testcontainers:mysql:_") + testImplementation("org.testcontainers:jdbc:_") + } // spring boot main application이므로 실행 가능한 jar를 생성한다. diff --git a/application/src/main/java/org/depromeet/spot/application/common/config/SecurityConfig.java b/application/src/main/java/org/depromeet/spot/application/common/config/SecurityConfig.java index 6263a3cf..7fcadfea 100644 --- a/application/src/main/java/org/depromeet/spot/application/common/config/SecurityConfig.java +++ b/application/src/main/java/org/depromeet/spot/application/common/config/SecurityConfig.java @@ -32,6 +32,7 @@ public class SecurityConfig { "/favicon.ico/**", "/api/v1/members/**", "/actuator/**", + "/login/oauth2/code/google/**", "/trackEvent" }; diff --git a/application/src/main/java/org/depromeet/spot/application/common/config/SpotApplicationConfig.java b/application/src/main/java/org/depromeet/spot/application/common/config/SpotApplicationConfig.java index 52d2b327..a2fac1a1 100644 --- a/application/src/main/java/org/depromeet/spot/application/common/config/SpotApplicationConfig.java +++ b/application/src/main/java/org/depromeet/spot/application/common/config/SpotApplicationConfig.java @@ -2,11 +2,15 @@ import org.depromeet.spot.infrastructure.InfrastructureConfig; import org.depromeet.spot.usecase.config.UsecaseConfig; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; -@ComponentScan(basePackages = {"org.depromeet.spot.application"}) @Configuration +@EnableConfigurationProperties +@ComponentScan(basePackages = {"org.depromeet.spot.application"}) +@ConfigurationPropertiesScan(basePackages = {"org.depromeet.spot.application"}) @Import(value = {UsecaseConfig.class, SwaggerConfig.class, InfrastructureConfig.class}) public class SpotApplicationConfig {} diff --git a/application/src/main/java/org/depromeet/spot/application/common/jwt/JwtAuthenticationFilter.java b/application/src/main/java/org/depromeet/spot/application/common/jwt/JwtAuthenticationFilter.java index bd32b1c8..d1563ef3 100644 --- a/application/src/main/java/org/depromeet/spot/application/common/jwt/JwtAuthenticationFilter.java +++ b/application/src/main/java/org/depromeet/spot/application/common/jwt/JwtAuthenticationFilter.java @@ -48,6 +48,10 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { Map.of( "/api/v1/members", Set.of("GET", "POST"), + "/api/v2/members", + Set.of("GET", "POST"), + "/login/oauth2/code/google", + Set.of("GET"), "/api/v1/members/delete", Set.of("DELETE"), "/api/v1/baseball-teams", @@ -66,7 +70,6 @@ protected void doFilterInternal( filterChain.doFilter(request, response); return; } - // header가 null이거나 빈 문자열이면 안됨. if (header == null || header.isEmpty()) { throw new CustomJwtException(JwtErrorCode.NONEXISTENT_TOKEN); diff --git a/application/src/main/java/org/depromeet/spot/application/common/jwt/JwtProperties.java b/application/src/main/java/org/depromeet/spot/application/common/jwt/JwtProperties.java new file mode 100644 index 00000000..28f76925 --- /dev/null +++ b/application/src/main/java/org/depromeet/spot/application/common/jwt/JwtProperties.java @@ -0,0 +1,6 @@ +package org.depromeet.spot.application.common.jwt; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "spring.jwt") +public record JwtProperties(String secret) {} diff --git a/application/src/main/java/org/depromeet/spot/application/common/jwt/JwtTokenUtil.java b/application/src/main/java/org/depromeet/spot/application/common/jwt/JwtTokenUtil.java index 97b787f0..8cf058cc 100644 --- a/application/src/main/java/org/depromeet/spot/application/common/jwt/JwtTokenUtil.java +++ b/application/src/main/java/org/depromeet/spot/application/common/jwt/JwtTokenUtil.java @@ -16,7 +16,6 @@ import org.depromeet.spot.application.common.exception.JwtErrorCode; import org.depromeet.spot.domain.member.Member; import org.depromeet.spot.domain.member.enums.MemberRole; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import io.jsonwebtoken.Claims; @@ -37,8 +36,7 @@ public class JwtTokenUtil { // JWT를 생성하고 관리하는 클래스 // 토큰에 사용되는 시크릿 키 - @Value("${spring.jwt.secret}") - private String SECRETKEY; + private final JwtProperties properties; public String getJWTToken(Member member) { // TODO 토큰 구현하기. @@ -61,13 +59,13 @@ public String generateToken(Long memberId, MemberRole memberRole) { .setClaims(createClaims(memberId, memberRole)) .setIssuedAt(current) .setExpiration(expiredAt) - .signWith(SignatureAlgorithm.HS256, SECRETKEY.getBytes()) + .signWith(SignatureAlgorithm.HS256, properties.secret().getBytes()) .compact(); } public Long getIdFromJWT(String token) { return Jwts.parser() - .setSigningKey(SECRETKEY.getBytes()) + .setSigningKey(properties.secret().getBytes()) .parseClaimsJws(token) .getBody() .get("memberId", Long.class); @@ -75,7 +73,7 @@ public Long getIdFromJWT(String token) { public String getRoleFromJWT(String token) { return Jwts.parser() - .setSigningKey(SECRETKEY.getBytes()) + .setSigningKey(properties.secret().getBytes()) .parseClaimsJws(token) .getBody() .get("role", String.class); @@ -124,7 +122,7 @@ private Map createClaims(Long memberId, MemberRole role) { } private Key createSignature() { - byte[] apiKeySecretBytes = SECRETKEY.getBytes(); + byte[] apiKeySecretBytes = properties.secret().getBytes(); return new SecretKeySpec(apiKeySecretBytes, SignatureAlgorithm.HS256.getJcaName()); } diff --git a/application/src/main/java/org/depromeet/spot/application/member/controller/OauthController.java b/application/src/main/java/org/depromeet/spot/application/member/controller/OauthController.java new file mode 100644 index 00000000..c4fca739 --- /dev/null +++ b/application/src/main/java/org/depromeet/spot/application/member/controller/OauthController.java @@ -0,0 +1,76 @@ +package org.depromeet.spot.application.member.controller; + +import jakarta.validation.Valid; + +import org.depromeet.spot.application.common.jwt.JwtTokenUtil; +import org.depromeet.spot.application.member.dto.request.RegisterV2Req; +import org.depromeet.spot.application.member.dto.response.JwtTokenResponse; +import org.depromeet.spot.domain.member.Member; +import org.depromeet.spot.domain.member.enums.SnsProvider; +import org.depromeet.spot.usecase.port.in.oauth.OauthUsecase; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RestController +@RequiredArgsConstructor +@Slf4j +@Tag(name = "(v2) Oauth") +public class OauthController { + + private final OauthUsecase oauthUsecase; + + private final JwtTokenUtil jwtTokenUtil; + + @PostMapping("/api/v2/members") + @ResponseStatus(HttpStatus.CREATED) + @Operation(summary = "Member 회원가입 API") + public JwtTokenResponse create(@RequestBody @Valid RegisterV2Req request) { + + Member member = request.toDomain(); + Member memberResult = oauthUsecase.create(request.accessToken(), member); + + return new JwtTokenResponse(jwtTokenUtil.getJWTToken(memberResult)); + } + + @GetMapping("/api/v2/members/{snsProvider}/{token}") + @ResponseStatus(HttpStatus.OK) + @Operation(summary = "Member 로그인 API") + public JwtTokenResponse login( + @PathVariable("snsProvider") + @Parameter(name = "snsProvider", description = "KAKAO/GOOGLE", required = true) + SnsProvider snsProvider, + @PathVariable("token") + @Parameter( + name = "token", + description = "sns 카카오는 accessToken, 구글은 authToken", + required = true) + String token) { + + Member member = oauthUsecase.login(snsProvider, token); + return new JwtTokenResponse(jwtTokenUtil.getJWTToken(member)); + } + + // TODO : /api/v2/members를 RequestMapping으로 빼면 구글 로그인에서 4xx Exception 발생 + @GetMapping("/api/v2/members/authorization/{snsProvider}") + @ResponseStatus(HttpStatus.OK) + @Operation(summary = "(백엔드용)accessToken을 받아오기 위한 API") + public String getAccessToken2( + @PathVariable("snsProvider") SnsProvider snsProvider, @RequestParam String code) { + String token = oauthUsecase.getOauthAccessToken(snsProvider, code); + log.info("snsProvider : {}", snsProvider); + log.info("token : \n{}", token); + return token; + } +} diff --git a/application/src/main/java/org/depromeet/spot/application/member/dto/request/RegisterV2Req.java b/application/src/main/java/org/depromeet/spot/application/member/dto/request/RegisterV2Req.java new file mode 100644 index 00000000..28f895b5 --- /dev/null +++ b/application/src/main/java/org/depromeet/spot/application/member/dto/request/RegisterV2Req.java @@ -0,0 +1,35 @@ +package org.depromeet.spot.application.member.dto.request; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; + +import org.depromeet.spot.domain.member.Member; +import org.depromeet.spot.domain.member.enums.SnsProvider; +import org.hibernate.validator.constraints.Length; +import org.hibernate.validator.constraints.Range; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record RegisterV2Req( + @NotNull(message = "인가 accessToken는 필수 값입니다.") @Schema(description = "카카오 인증 accessToken") + String accessToken, + @NotNull(message = "닉네임 값은 필수입니다.") + @Schema(description = "설정하려는 닉네임") + @Length(min = 2, max = 10, message = "닉네임은 2글자에서 10글자 사이여야합니다.") + @Pattern( + regexp = "^[a-zA-Z0-9가-힣]*$", + message = "닉네임은 알파벳 대소문자, 숫자, 한글만 허용하며, 공백은 불가능합니다.") + String nickname, + @Schema(description = "응원 팀 pk") + @Range( + min = 1, + max = 10, + message = "응원 팀은 null(모두 응원), 1번(두산 베어스)부터 10번(NC 다이노스)까지 입니다.") + Long teamId, + @NotNull(message = "SNS Provider는 필수 값입니다.") @Schema(description = "KAKAO/GOOGLE") + SnsProvider snsProvider) { + + public Member toDomain() { + return Member.builder().nickname(nickname).teamId(teamId).snsProvider(snsProvider).build(); + } +} diff --git a/application/src/main/resources/application.yaml b/application/src/main/resources/application.yaml index 792f06e6..aa4a88d8 100644 --- a/application/src/main/resources/application.yaml +++ b/application/src/main/resources/application.yaml @@ -11,20 +11,20 @@ spring: - jpa - aws - jwt - - kakao + - oauth - mixpanel dev: - jpa - aws - jwt - - kakao + - oauth - monitoring - mixpanel prod: - jpa - aws - jwt - - kakao + - oauth - sentry - monitoring - mixpanel diff --git a/application/src/test/java/org/depromeet/spot/application/ReviewLikeServiceTest.java b/application/src/test/java/org/depromeet/spot/application/ReviewLikeServiceTest.java new file mode 100644 index 00000000..e9d99d9a --- /dev/null +++ b/application/src/test/java/org/depromeet/spot/application/ReviewLikeServiceTest.java @@ -0,0 +1,119 @@ +package org.depromeet.spot.application; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicLong; + +import org.depromeet.spot.domain.member.Level; +import org.depromeet.spot.domain.member.Member; +import org.depromeet.spot.domain.member.enums.MemberRole; +import org.depromeet.spot.domain.member.enums.SnsProvider; +import org.depromeet.spot.domain.review.Review; +import org.depromeet.spot.usecase.port.in.review.ReadReviewUsecase; +import org.depromeet.spot.usecase.port.out.member.LevelRepository; +import org.depromeet.spot.usecase.port.out.member.MemberRepository; +import org.depromeet.spot.usecase.service.review.like.ReviewLikeService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.Sql.ExecutionPhase; +import org.springframework.test.context.jdbc.SqlGroup; +import org.springframework.transaction.annotation.Transactional; +import org.testcontainers.junit.jupiter.Testcontainers; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@SpringBootTest +@Testcontainers +@ActiveProfiles("test") +@TestPropertySource("classpath:application-test.yml") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@SqlGroup({ + @Sql( + value = "/sql/delete-data-after-review-like.sql", + executionPhase = ExecutionPhase.AFTER_TEST_METHOD), + @Sql( + value = "/sql/review-like-service-data.sql", + executionPhase = ExecutionPhase.BEFORE_TEST_METHOD), +}) +class ReviewLikeServiceTest { + + @Autowired private ReviewLikeService reviewLikeService; + + @Autowired private ReadReviewUsecase readReviewUsecase; + + @Autowired private MemberRepository memberRepository; + + @Autowired private LevelRepository levelRepository; + + private static final int NUMBER_OF_THREAD = 100; + + @BeforeEach + @Transactional + void init() { + Level level = levelRepository.findByValue(0); + AtomicLong memberIdGenerator = new AtomicLong(1); + + for (int i = 0; i < NUMBER_OF_THREAD; i++) { + long memberId = memberIdGenerator.getAndIncrement(); + memberRepository.save( + Member.builder() + .id(memberId) + .snsProvider(SnsProvider.KAKAO) + .teamId(1L) + .role(MemberRole.ROLE_ADMIN) + .idToken("idToken" + memberId) + .nickname(String.valueOf(memberId)) + .phoneNumber(String.valueOf(memberId)) + .email("email" + memberId) + .build(), + level); + } + } + + @Test + void 멀티_스레드_환경에서_리뷰_공감_수를_정상적으로_증가시킬_수_있다() throws InterruptedException { + // given + final long reviewId = 1L; + AtomicLong memberIdGenerator = new AtomicLong(1); + final ExecutorService executorService = Executors.newFixedThreadPool(NUMBER_OF_THREAD); + final CountDownLatch latch = new CountDownLatch(NUMBER_OF_THREAD); + + // when + for (int i = 0; i < NUMBER_OF_THREAD; i++) { + long memberId = memberIdGenerator.getAndIncrement(); + executorService.execute( + () -> { + try { + reviewLikeService.toggleLike(memberId, reviewId); + System.out.println( + "Thread " + Thread.currentThread().getId() + " - 성공"); + } catch (Throwable e) { + System.out.println( + "Thread " + + Thread.currentThread().getId() + + " - 실패" + + e.getClass().getName()); + e.printStackTrace(); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executorService.shutdown(); + + // then + Review review = readReviewUsecase.findById(reviewId); + assertEquals(100, review.getLikesCount()); + } +} diff --git a/application/src/test/resources/application-test.yml b/application/src/test/resources/application-test.yml new file mode 100644 index 00000000..2850ede1 --- /dev/null +++ b/application/src/test/resources/application-test.yml @@ -0,0 +1,45 @@ +loki: + url: ${LOKI_URL} + +aws: + s3: + accessKey: ${AWS_S3_ACCESS_KEY} + secretKey: ${AWS_S3_SECRET_KEY} + bucketName: ${AWS_S3_BUCKET_NAME} + redis: + host: localhost + port: 6379 + +oauth: + kakaoClientId: ${KAKAO_CLIENT_ID} + kakaoAuthTokenUrlHost: ${KAKAO_AUTH_TOKEN_URL_HOST} + kakaoAuthUserUrlHost: ${KAKAO_AUTH_USER_URL_HOST} + kakaoRedirectUrl: ${KAKAO_REDIRECT_URL} + googleClientId: ${GOOGLE_CLIENT_ID} + googleClientSecret: ${GOOGLE_CLIENT_SECRET} + googleRedirectUrl: ${GOOGLE_REDIRECT_URL} + googleAuthTokenUrlHost: ${GOOGLE_AUTH_TOKEN_URL_HOST} + googleUserUrlHost: ${GOOGLE_USER_URL_HOST} + +spring: + datasource: + url: jdbc:tc:mysql:8.0.36:///testdb + username: testuser + password: testpassword + driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver + hikari: + connection-timeout: 100000 + maximum-pool-size: 300 + max-lifetime: 100000 + jpa: + database: mysql + hibernate: + ddl-auto: create + database-platform: org.hibernate.dialect.MySQL8Dialect + defer-datasource-initialization: true + jwt: + secret: ${JWT_SECRETKEY} + + +server: + port: 8080 \ No newline at end of file diff --git a/application/src/test/resources/sql/delete-data-after-review-like.sql b/application/src/test/resources/sql/delete-data-after-review-like.sql new file mode 100644 index 00000000..7639a790 --- /dev/null +++ b/application/src/test/resources/sql/delete-data-after-review-like.sql @@ -0,0 +1,10 @@ +delete from members where id > 0; +delete from reviews where id > 0; +delete from review_likes where id > 0; +delete from levels where id > 0; +delete from stadiums where id > 0; +delete from baseball_teams where id > 0; +delete from sections where id > 0; +delete from blocks where id > 0; +delete from block_rows where id > 0; +delete from seats where id > 0; \ No newline at end of file diff --git a/application/src/test/resources/sql/review-like-service-data.sql b/application/src/test/resources/sql/review-like-service-data.sql new file mode 100644 index 00000000..18de5f2c --- /dev/null +++ b/application/src/test/resources/sql/review-like-service-data.sql @@ -0,0 +1,41 @@ +-- levels +INSERT INTO levels (id, value, title, mascot_image_url, created_at, updated_at, deleted_at) +VALUES (1, 0, '직관 꿈나무', null, null, null, null), + (2, 1, '직관 첫 걸음', null, null, null, null), + (3, 2, '경기장 탐험가', null, null, null, null), + (4, 3, '직관의 여유', null, null, null, null), + (5, 4, '응원 단장', null, null, null, null), + (6, 5, '야구장 VIP', null, null, null, null), + (7, 6, '전설의 직관러', null, null, null, null); + +-- Stadiums +INSERT INTO stadiums (id, name, main_image, seating_chart_image, labeled_seating_chart_image, + is_active) +VALUES (1, '잠실 야구 경기장', 'main_image_a.jpg', 'seating_chart_a.jpg', 'labeled_seating_chart_a.jpg', + 1); + +-- Baseball Teams +INSERT INTO baseball_teams (id, name, alias, logo, label_font_color) +VALUES (1, 'Team A', 'A', 'logo_a.png', '#FFFFFF'); + +-- Stadium Sections +INSERT INTO sections (id, stadium_id, name, alias) +VALUES (1, 1, '오렌지석', '응원석'); + +-- Block +INSERT INTO blocks (id, stadium_id, section_id, code, max_rows) +VALUES (1, 1, 1, "207", 3); + +-- Row +INSERT INTO block_rows (id, block_id, number, max_seats) +VALUES (1, 1, 1, 3); + +-- Seats +INSERT INTO seats (id, stadium_id, section_id, block_id, row_id, seat_number) +VALUES (1, 1, 1, 1, 1, 1); + +-- reviews +INSERT INTO reviews (id, member_id, stadium_id, section_id, block_id, row_id, seat_id, date_time, content, likes_count, scraps_count, review_type) +VALUES + (1, 1, 1, 1, 1, 1, 1, '2023-06-01 19:00:00', '좋은 경기였습니다!', 0, 0, 'VIEW'), + (2, 1, 1, 1, 1, 1, 1, '2023-06-01 19:00:00', '좋은 경기였습니다!', 0, 0, 'VIEW'); \ No newline at end of file diff --git a/common/src/main/java/org/depromeet/spot/common/annotation/DistributedLock.java b/common/src/main/java/org/depromeet/spot/common/annotation/DistributedLock.java new file mode 100644 index 00000000..d0fa047f --- /dev/null +++ b/common/src/main/java/org/depromeet/spot/common/annotation/DistributedLock.java @@ -0,0 +1,20 @@ +package org.depromeet.spot.common.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.concurrent.TimeUnit; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface DistributedLock { + + String key(); + + TimeUnit timeUnit() default TimeUnit.SECONDS; + + long leaseTime() default 5L; + + long waitTime() default 5L; +} diff --git a/domain/src/main/java/org/depromeet/spot/domain/member/enums/SnsProvider.java b/domain/src/main/java/org/depromeet/spot/domain/member/enums/SnsProvider.java index f5cc1349..a1c32614 100644 --- a/domain/src/main/java/org/depromeet/spot/domain/member/enums/SnsProvider.java +++ b/domain/src/main/java/org/depromeet/spot/domain/member/enums/SnsProvider.java @@ -6,7 +6,8 @@ @Getter @AllArgsConstructor public enum SnsProvider { - KAKAO("KAKAO"); + KAKAO("KAKAO"), + GOOGLE("GOOGLE"); private final String value; } diff --git a/infrastructure/build.gradle.kts b/infrastructure/build.gradle.kts index fdb8a7c7..04303df5 100644 --- a/infrastructure/build.gradle.kts +++ b/infrastructure/build.gradle.kts @@ -9,6 +9,8 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-cache") implementation("org.springframework.boot:spring-boot-starter-data-jpa:_") + + // mysql runtimeOnly("com.mysql:mysql-connector-j") @@ -27,6 +29,10 @@ dependencies { // redis implementation("org.springframework.boot:spring-boot-starter-data-redis:_") implementation("org.redisson:redisson-spring-boot-starter:_") + implementation("it.ozimov:embedded-redis:_") { + exclude(group = "org.slf4j", module = "slf4j-simple") + because("테스트 환경에서 사용할 embedded-redis") + } // webflux (HTTP 요청에 사용) implementation("org.springframework.boot:spring-boot-starter-webflux") diff --git a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/aws/config/ObjectStorageConfig.java b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/aws/config/ObjectStorageConfig.java index c46aaaac..a36cd98f 100644 --- a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/aws/config/ObjectStorageConfig.java +++ b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/aws/config/ObjectStorageConfig.java @@ -3,7 +3,6 @@ import org.depromeet.spot.infrastructure.aws.property.ObjectStorageProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Profile; import com.amazonaws.auth.AWSStaticCredentialsProvider; import com.amazonaws.auth.BasicAWSCredentials; @@ -14,7 +13,6 @@ import lombok.RequiredArgsConstructor; @Configuration -@Profile("!test") @RequiredArgsConstructor public class ObjectStorageConfig { diff --git a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/common/aop/DistributedLockAop.java b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/common/aop/DistributedLockAop.java new file mode 100644 index 00000000..8fa11054 --- /dev/null +++ b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/common/aop/DistributedLockAop.java @@ -0,0 +1,70 @@ +package org.depromeet.spot.infrastructure.common.aop; + +import java.lang.reflect.Method; +import java.util.Arrays; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.depromeet.spot.common.annotation.DistributedLock; +import org.depromeet.spot.infrastructure.common.util.SpringELParser; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class DistributedLockAop { + + private final RedissonClient redissonClient; + private final TransactionAop aopForTransaction; + + private static final String REDISSON_LOCK_PREFIX = "LOCK:"; + + @Around("@annotation(org.depromeet.spot.common.annotation.DistributedLock)") + public Object lock(ProceedingJoinPoint joinPoint) throws Throwable { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + DistributedLock distributedLock = method.getAnnotation(DistributedLock.class); + + String lockKey = generateLockKey(signature, joinPoint.getArgs(), distributedLock); + RLock rLock = redissonClient.getLock(lockKey); + + try { + if (!acquireLock(rLock, distributedLock)) { + return false; + } + return aopForTransaction.proceed(joinPoint); + } catch (InterruptedException e) { + log.error(Arrays.toString(e.getStackTrace())); + throw e; + } finally { + releaseLock(rLock); + } + } + + private String generateLockKey( + MethodSignature signature, Object[] args, DistributedLock distributeLock) { + return REDISSON_LOCK_PREFIX + + SpringELParser.getDynamicValue( + signature.getParameterNames(), args, distributeLock.key()); + } + + private boolean acquireLock(RLock rLock, DistributedLock distributeLock) + throws InterruptedException { + return rLock.tryLock( + distributeLock.waitTime(), distributeLock.leaseTime(), distributeLock.timeUnit()); + } + + private void releaseLock(RLock rLock) { + if (rLock != null && rLock.isHeldByCurrentThread()) { + rLock.unlock(); + } + } +} diff --git a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/common/aop/TransactionAop.java b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/common/aop/TransactionAop.java new file mode 100644 index 00000000..eb977092 --- /dev/null +++ b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/common/aop/TransactionAop.java @@ -0,0 +1,15 @@ +package org.depromeet.spot.infrastructure.common.aop; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Component +public class TransactionAop { + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public Object proceed(ProceedingJoinPoint joinPoint) throws Throwable { + return joinPoint.proceed(); + } +} diff --git a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/common/util/SpringELParser.java b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/common/util/SpringELParser.java new file mode 100644 index 00000000..cd3a6d88 --- /dev/null +++ b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/common/util/SpringELParser.java @@ -0,0 +1,19 @@ +package org.depromeet.spot.infrastructure.common.util; + +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; + +public class SpringELParser { + + public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) { + ExpressionParser parser = new SpelExpressionParser(); + StandardEvaluationContext context = new StandardEvaluationContext(); + + for (int i = 0; i < parameterNames.length; i++) { + context.setVariable(parameterNames[i], args[i]); + } + + return parser.parseExpression(key).getValue(context, Object.class); + } +} diff --git a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/OauthRepositoryImpl.java b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/OauthRepositoryImpl.java index a3d7fd70..628ef0de 100644 --- a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/OauthRepositoryImpl.java +++ b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/OauthRepositoryImpl.java @@ -3,50 +3,47 @@ import org.depromeet.spot.common.exception.oauth.OauthException.InternalOauthServerException; import org.depromeet.spot.common.exception.oauth.OauthException.InvalidAcessTokenException; import org.depromeet.spot.domain.member.Member; +import org.depromeet.spot.domain.member.enums.SnsProvider; +import org.depromeet.spot.infrastructure.jpa.oauth.config.OauthProperties; +import org.depromeet.spot.infrastructure.jpa.oauth.entity.GoogleTokenEntity; +import org.depromeet.spot.infrastructure.jpa.oauth.entity.GoogleUserInfoEntity; import org.depromeet.spot.infrastructure.jpa.oauth.entity.KakaoTokenEntity; import org.depromeet.spot.infrastructure.jpa.oauth.entity.KakaoUserInfoEntity; import org.depromeet.spot.usecase.port.out.oauth.OauthRepository; -import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatusCode; import org.springframework.stereotype.Repository; import org.springframework.web.reactive.function.client.WebClient; import io.netty.handler.codec.http.HttpHeaderValues; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import reactor.core.publisher.Mono; @Slf4j @Repository +@RequiredArgsConstructor public class OauthRepositoryImpl implements OauthRepository { private final String BEARER = "Bearer"; + private final OauthProperties properties; - // kakao에서 발급 받은 clientID - @Value("${oauth.clientId}") - private String CLIENT_ID; - - @Value("${oauth.kauthTokenUrlHost}") - private String KAUTH_TOKEN_URL_HOST; - - // 엑세스 토큰으로 카카오에서 유저 정보 받아오기 - @Value("${oauth.kauthUserUrlHost}") - private String KAUTH_USER_URL_HOST; + private final String AUTHORIZATION_CODE = "authorization_code"; @Override - public String getKakaoAccessToken(String idCode) { + public String getKakaoAccessToken(String authorizationCode) { // Webflux의 WebClient KakaoTokenEntity kakaoTokenEntity = - WebClient.create(KAUTH_TOKEN_URL_HOST) + WebClient.create(properties.kakaoAuthTokenUrlHost()) .post() .uri( uriBuilder -> uriBuilder .scheme("https") .path("/oauth/token") - .queryParam("grant_type", "authorization_code") - .queryParam("client_id", CLIENT_ID) - .queryParam("code", idCode) + .queryParam("grant_type", AUTHORIZATION_CODE) + .queryParam("client_id", properties.kakaoClientId()) + .queryParam("code", authorizationCode) .build(true)) .header( HttpHeaders.CONTENT_TYPE, @@ -71,26 +68,111 @@ public String getKakaoAccessToken(String idCode) { } @Override - public Member getRegisterUserInfo(String accessToken, Member member) { - KakaoUserInfoEntity userInfo = getUserInfo(accessToken); + public String getOauthAccessToken(SnsProvider snsProvider, String authorizationCode) { + String authTokenUrlHost; + + switch (snsProvider) { + case KAKAO: + authTokenUrlHost = properties.kakaoAuthTokenUrlHost(); + break; + default: + authTokenUrlHost = properties.googleAuthTokenUrlHost(); + break; + } + + String accessToken = + WebClient.create(authTokenUrlHost) + .post() + .uri( + uriBuilder -> { + switch (snsProvider) { + case KAKAO: + return uriBuilder + .scheme("https") + .path("/oauth/token") + .queryParam("grant_type", AUTHORIZATION_CODE) + .queryParam( + "client_id", properties.kakaoClientId()) + .queryParam( + "redirect_uri", + properties.kakaoRedirectUrl()) + .queryParam("code", authorizationCode) + .build(true); + default: // 기본적으로 GOOGLE 처리 + return uriBuilder + .scheme("https") + .path("/token") + .queryParam("grant_type", AUTHORIZATION_CODE) + .queryParam( + "client_id", + properties.googleClientId()) + .queryParam( + "client_secret", + properties.googleClientSecret()) + .queryParam( + "redirect_uri", + properties.googleRedirectUrl()) + .queryParam("code", authorizationCode) + .build(true); + } + }) + .header( + HttpHeaders.CONTENT_TYPE, + HttpHeaderValues.APPLICATION_X_WWW_FORM_URLENCODED.toString()) + .retrieve() + .onStatus( + HttpStatusCode::is4xxClientError, + clientResponse -> Mono.error(new InvalidAcessTokenException())) + .onStatus( + HttpStatusCode::is5xxServerError, + clientResponse -> Mono.error(new InternalOauthServerException())) + .bodyToMono(GoogleTokenEntity.class) + .block() + .getAccessToken(); + + return accessToken; + } + + @Override + public Member getKakaoRegisterUserInfo(String accessToken, Member member) { + KakaoUserInfoEntity userInfo = getKakaoUserInfo(accessToken); // 회원가입 시 받은 정보를 바탕으로 member로 변환해서 리턴. return userInfo.toKakaoDomain(member); } + @Override + public Member getOauthRegisterUserInfo(String accessToken, Member member) { + switch (member.getSnsProvider()) { + case KAKAO: + return getKakaoUserInfo(accessToken).toKakaoDomain(member); + default: + return getGoogleUserInfo(accessToken).toGoogleDomain(member); + } + } + @Override public Member getLoginUserInfo(String accesstoken) { - KakaoUserInfoEntity userInfo = getUserInfo(accesstoken); + KakaoUserInfoEntity userInfo = getKakaoUserInfo(accesstoken); - // TODO : idToken이 변경 될 수 있음. 등록된 email도 변경될 수 있기에 추 후 논의가 필요. // 기존 유저와 비교를 위해선 idToken만 필요함. // 앱에서는 accessToken을 반환해주기에 accessToken으로 로직 처리 return userInfo.toLoginDomain(); } - public KakaoUserInfoEntity getUserInfo(String accessToken) { + @Override + public Member getOauthLoginUserInfo(SnsProvider snsProvider, String accesstoken) { + switch (snsProvider) { + case KAKAO: + return getKakaoUserInfo(accesstoken).toLoginDomain(); + default: + return getGoogleUserInfo(accesstoken).toLoginDomain(); + } + } + + public KakaoUserInfoEntity getKakaoUserInfo(String accessToken) { KakaoUserInfoEntity userInfo = - WebClient.create(KAUTH_USER_URL_HOST) + WebClient.create(properties.kakaoAuthUserUrlHost()) .get() .uri( uriBuilder -> @@ -102,7 +184,6 @@ public KakaoUserInfoEntity getUserInfo(String accessToken) { HttpHeaders.CONTENT_TYPE, HttpHeaderValues.APPLICATION_X_WWW_FORM_URLENCODED.toString()) .retrieve() - // TODO : Custom Exception .onStatus( HttpStatusCode::is4xxClientError, clientResponse -> Mono.error(new InvalidAcessTokenException())) @@ -114,4 +195,33 @@ public KakaoUserInfoEntity getUserInfo(String accessToken) { return userInfo; } + + public GoogleUserInfoEntity getGoogleUserInfo(String accessToken) { + GoogleUserInfoEntity userInfo = + WebClient.create(properties.googleUserUrlHost()) + .get() + .uri( + uriBuilder -> + uriBuilder + .scheme("https") + .path("/oauth2/v3/userinfo") + .build(true)) + .header( + HttpHeaders.AUTHORIZATION, + BEARER + " " + accessToken) // access token 인가 + .header( + HttpHeaders.CONTENT_TYPE, + HttpHeaderValues.APPLICATION_X_WWW_FORM_URLENCODED.toString()) + .retrieve() + .onStatus( + HttpStatusCode::is4xxClientError, + clientResponse -> Mono.error(new InvalidAcessTokenException())) + .onStatus( + HttpStatusCode::is5xxServerError, + clientResponse -> Mono.error(new InternalOauthServerException())) + .bodyToMono(GoogleUserInfoEntity.class) + .block(); + + return userInfo; + } } diff --git a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/config/OauthProperties.java b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/config/OauthProperties.java new file mode 100644 index 00000000..0371f122 --- /dev/null +++ b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/config/OauthProperties.java @@ -0,0 +1,15 @@ +package org.depromeet.spot.infrastructure.jpa.oauth.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "oauth") +public record OauthProperties( + String kakaoClientId, + String kakaoAuthTokenUrlHost, + String kakaoAuthUserUrlHost, + String kakaoRedirectUrl, + String googleClientId, + String googleClientSecret, + String googleRedirectUrl, + String googleAuthTokenUrlHost, + String googleUserUrlHost) {} diff --git a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/entity/GoogleTokenEntity.java b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/entity/GoogleTokenEntity.java new file mode 100644 index 00000000..222945c3 --- /dev/null +++ b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/entity/GoogleTokenEntity.java @@ -0,0 +1,36 @@ +package org.depromeet.spot.infrastructure.jpa.oauth.entity; + +import org.depromeet.spot.infrastructure.jpa.common.entity.BaseEntity; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor // 역직렬화를 위한 기본 생성자 +@JsonIgnoreProperties(ignoreUnknown = true) +public class GoogleTokenEntity extends BaseEntity { + + @JsonProperty("token_type") + public String tokenType; + + @JsonProperty("access_token") + public String accessToken; + + @JsonProperty("id_token") + public String idToken; + + @JsonProperty("expires_in") + public Integer expiresIn; + + @JsonProperty("refresh_token") + public String refreshToken; + + @JsonProperty("refresh_token_expires_in") + public Integer refreshTokenExpiresIn; + + @JsonProperty("scope") + public String scope; +} diff --git a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/entity/GoogleUserInfoEntity.java b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/entity/GoogleUserInfoEntity.java new file mode 100644 index 00000000..07d2b20d --- /dev/null +++ b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/entity/GoogleUserInfoEntity.java @@ -0,0 +1,56 @@ +package org.depromeet.spot.infrastructure.jpa.oauth.entity; + +import org.depromeet.spot.domain.member.Member; +import org.depromeet.spot.domain.member.enums.MemberRole; +import org.depromeet.spot.domain.member.enums.SnsProvider; +import org.depromeet.spot.infrastructure.jpa.common.entity.BaseEntity; +import org.springframework.beans.factory.annotation.Value; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor // 역직렬화를 위한 기본 생성자 +@JsonIgnoreProperties(ignoreUnknown = true) +public class GoogleUserInfoEntity extends BaseEntity { + + @Value("${aws.s3.basicProfileImageUrl}") + private String BASIC_PROFILE_IMAGE_URL; + + // 구글 로그인은 sub라는 이름으로 id값을 줌. + // 구글의 sub 값은 Long 타입을 넘어감. + // BigInteger로 처리하거나 String으로 처리해야함. + @JsonProperty("sub") + public String idToken; + + // 풀네임(닉네임) + @JsonProperty("name") + public String nickname; + + // 구글 이메일 + @JsonProperty("email") + public String email; + + // 프로필사진 + @JsonProperty("picture") + public String profileImageUrl; + + public Member toGoogleDomain(Member member) { + return Member.builder() + .email(email) + .nickname(member.getNickname()) + .profileImage(BASIC_PROFILE_IMAGE_URL) + .snsProvider(SnsProvider.GOOGLE) + .idToken(idToken) + .role(MemberRole.ROLE_USER) + .teamId(member.getTeamId()) + .build(); + } + + public Member toLoginDomain() { + return Member.builder().idToken(idToken).build(); + } +} diff --git a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/entity/KakaoUserInfoEntity.java b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/entity/KakaoUserInfoEntity.java index de6d0db0..1708a1d1 100644 --- a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/entity/KakaoUserInfoEntity.java +++ b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/entity/KakaoUserInfoEntity.java @@ -8,6 +8,7 @@ import org.depromeet.spot.domain.member.enums.MemberRole; import org.depromeet.spot.domain.member.enums.SnsProvider; import org.depromeet.spot.infrastructure.jpa.common.entity.BaseEntity; +import org.springframework.beans.factory.annotation.Value; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; @@ -21,6 +22,9 @@ @JsonIgnoreProperties(ignoreUnknown = true) public class KakaoUserInfoEntity extends BaseEntity { + @Value("${aws.s3.basicProfileImageUrl}") + private String BASIC_PROFILE_IMAGE_URL; + // 서비스에 연결 완료된 시각. UTC @JsonProperty("connected_at") public Date connectedAt; @@ -82,7 +86,7 @@ public class Profile { // 닉네임 @JsonProperty("nickname") - public String nickName; + public String nickname; // 프로필 미리보기 이미지 URL @JsonProperty("thumbnail_image_url") @@ -100,7 +104,7 @@ public Member toKakaoDomain(Member member) { .name(kakaoAccount.name) .nickname(member.getNickname()) .phoneNumber(kakaoAccount.phoneNumber) - .profileImage(kakaoAccount.profile.profileImageUrl) + .profileImage(BASIC_PROFILE_IMAGE_URL) .snsProvider(SnsProvider.KAKAO) .idToken(getId().toString()) .role(MemberRole.ROLE_USER) diff --git a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/review/entity/ReviewEntity.java b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/review/entity/ReviewEntity.java index f9eacd7a..6e97237e 100644 --- a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/review/entity/ReviewEntity.java +++ b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/review/entity/ReviewEntity.java @@ -105,7 +105,7 @@ public class ReviewEntity extends BaseEntity { private Integer scrapsCount; @Enumerated(EnumType.STRING) - @ColumnDefault("VIEW") + @ColumnDefault("'VIEW'") @Column(name = "review_type", nullable = false) private ReviewType reviewType; diff --git a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/redis/EmbeddedRedisConfig.java b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/redis/EmbeddedRedisConfig.java new file mode 100644 index 00000000..73385e03 --- /dev/null +++ b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/redis/EmbeddedRedisConfig.java @@ -0,0 +1,109 @@ +package org.depromeet.spot.infrastructure.redis; + +import java.io.File; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.util.Objects; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; + +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.core.io.ClassPathResource; + +import lombok.extern.slf4j.Slf4j; +import redis.embedded.RedisServer; + +@Slf4j +@Configuration +@Profile("local | test") +public class EmbeddedRedisConfig { + + private static final String REDISSON_HOST_PREFIX = "redis://localhost:"; + private static final int REDIS_DEFAULT_PORT = 6379; + + private RedisServer redisServer; + private final int embeddedRedisPort; + + public EmbeddedRedisConfig() { + this.embeddedRedisPort = + isPortInUse(REDIS_DEFAULT_PORT) ? findAvailablePort() : REDIS_DEFAULT_PORT; + log.info("embedded redis port = {}", embeddedRedisPort); + } + + @PostConstruct + public void redisServer() { + // redisServer = new RedisServer(embeddedRedisPort); + if (isArmMac()) { + redisServer = new RedisServer(getRedisFileForArcMac(), embeddedRedisPort); + } else { + redisServer = + RedisServer.builder() + .port(embeddedRedisPort) + .setting("maxmemory 128M") // maxheap 128M + .build(); + } + try { + redisServer.start(); + } catch (Exception e) { + e.printStackTrace(); + } + } + + @PreDestroy + public void stopRedis() { + if (redisServer != null) { + redisServer.stop(); + } + } + + @Bean + public RedissonClient redissonClient() { + Config redissonConfig = new Config(); + redissonConfig.useSingleServer().setAddress(REDISSON_HOST_PREFIX + embeddedRedisPort); + return Redisson.create(redissonConfig); + } + + /** + * 현재 시스템이 ARM 아키텍처를 사용하는 MAC인지 확인 System.getProperty("os.arch") : JVM이 실행되는 시스템 아키텍처 반환 + * System.getProperty("os.name") : 시스템 이름 반환 + */ + private boolean isArmMac() { + return Objects.equals(System.getProperty("os.arch"), "aarch64") + && Objects.equals(System.getProperty("os.name"), "Mac OS X"); + } + + /** ARM 아키텍처를 사용하는 Mac에서 실행할 수 있는 Redis 바이너리 파일을 반환 */ + private File getRedisFileForArcMac() { + try { + return new ClassPathResource("binary/redis/redis-server-7.2.3-mac-arm64").getFile(); + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } + + private boolean isPortInUse(final int port) { + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress("localhost", port), 200); + return true; + } catch (IOException e) { + return false; + } + } + + private int findAvailablePort() { + for (int port = 10000; port <= 65535; port++) { + if (!isPortInUse(port)) { + return port; + } + } + throw new IllegalArgumentException("10000 ~ 65535 사이에서 사용 가능한 포트가 없습니다."); + } +} diff --git a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/redis/RedisConfig.java b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/redis/RedissonConfig.java similarity index 89% rename from infrastructure/src/main/java/org/depromeet/spot/infrastructure/redis/RedisConfig.java rename to infrastructure/src/main/java/org/depromeet/spot/infrastructure/redis/RedissonConfig.java index 8e004ed7..fd7e74f8 100644 --- a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/redis/RedisConfig.java +++ b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/redis/RedissonConfig.java @@ -5,12 +5,14 @@ import org.redisson.config.Config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import lombok.RequiredArgsConstructor; @Configuration +@Profile("dev | prod") @RequiredArgsConstructor -public class RedisConfig { +public class RedissonConfig { private static final String REDISSON_HOST_PREFIX = "redis://"; private final RedisProperties redisProperties; diff --git a/usecase/src/main/java/org/depromeet/spot/usecase/port/in/member/MemberUsecase.java b/usecase/src/main/java/org/depromeet/spot/usecase/port/in/member/MemberUsecase.java index 52a55a09..b5cabe43 100644 --- a/usecase/src/main/java/org/depromeet/spot/usecase/port/in/member/MemberUsecase.java +++ b/usecase/src/main/java/org/depromeet/spot/usecase/port/in/member/MemberUsecase.java @@ -11,7 +11,7 @@ public interface MemberUsecase { Member create(String accessToken, Member member); - Member login(String idCode); + Member login(String accessToken); boolean duplicatedNickname(String nickname); diff --git a/usecase/src/main/java/org/depromeet/spot/usecase/port/in/oauth/OauthUsecase.java b/usecase/src/main/java/org/depromeet/spot/usecase/port/in/oauth/OauthUsecase.java new file mode 100644 index 00000000..ac45b362 --- /dev/null +++ b/usecase/src/main/java/org/depromeet/spot/usecase/port/in/oauth/OauthUsecase.java @@ -0,0 +1,62 @@ +package org.depromeet.spot.usecase.port.in.oauth; + +import org.depromeet.spot.domain.member.Member; +import org.depromeet.spot.domain.member.enums.SnsProvider; +import org.depromeet.spot.domain.team.BaseballTeam; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +public interface OauthUsecase { + + Member create(String accessToken, Member member); + + Member login(SnsProvider snsProvider, String token); + + String getOauthAccessToken(SnsProvider snsProvider, String authorizationCode); + + @Getter + @Builder + @AllArgsConstructor + class MemberInfo { + private final String nickname; + private final String profileImageUrl; + private final int level; + private final String levelTitle; + private final String mascotImageUrl; + private String teamImageUrl; + private final Long teamId; + private final String teamName; + private final Long reviewCntToLevelUp; + + public static MemberInfo of(Member member, Long reviewCntToLevelUp) { + return MemberInfo.builder() + .nickname(member.getNickname()) + .profileImageUrl(member.getProfileImage()) + .level(member.getLevel().getValue()) + .levelTitle(member.getLevel().getTitle()) + .mascotImageUrl(member.getLevel().getMascotImageUrl()) + .teamImageUrl(null) + .teamId(null) + .teamName(null) + .reviewCntToLevelUp(reviewCntToLevelUp) + .build(); + } + + public static MemberInfo of( + Member member, BaseballTeam baseballTeam, Long reviewCntToLevelUp) { + return MemberInfo.builder() + .nickname(member.getNickname()) + .profileImageUrl(member.getProfileImage()) + .level(member.getLevel().getValue()) + .levelTitle(member.getLevel().getTitle()) + .mascotImageUrl(member.getLevel().getMascotImageUrl()) + .teamImageUrl(baseballTeam.getLogo()) + .teamId(baseballTeam.getId()) + .teamName(baseballTeam.getName()) + .reviewCntToLevelUp(reviewCntToLevelUp) + .build(); + } + } +} diff --git a/usecase/src/main/java/org/depromeet/spot/usecase/port/in/review/like/ReviewLikeUsecase.java b/usecase/src/main/java/org/depromeet/spot/usecase/port/in/review/like/ReviewLikeUsecase.java index e4335f49..105dc1fb 100644 --- a/usecase/src/main/java/org/depromeet/spot/usecase/port/in/review/like/ReviewLikeUsecase.java +++ b/usecase/src/main/java/org/depromeet/spot/usecase/port/in/review/like/ReviewLikeUsecase.java @@ -1,5 +1,5 @@ package org.depromeet.spot.usecase.port.in.review.like; public interface ReviewLikeUsecase { - void toggleLike(long memberId, long reviewId); + void toggleLike(Long memberId, long reviewId); } diff --git a/usecase/src/main/java/org/depromeet/spot/usecase/port/out/oauth/OauthRepository.java b/usecase/src/main/java/org/depromeet/spot/usecase/port/out/oauth/OauthRepository.java index 95543e08..b2c42a35 100644 --- a/usecase/src/main/java/org/depromeet/spot/usecase/port/out/oauth/OauthRepository.java +++ b/usecase/src/main/java/org/depromeet/spot/usecase/port/out/oauth/OauthRepository.java @@ -1,12 +1,19 @@ package org.depromeet.spot.usecase.port.out.oauth; import org.depromeet.spot.domain.member.Member; +import org.depromeet.spot.domain.member.enums.SnsProvider; public interface OauthRepository { - String getKakaoAccessToken(String idCode); + String getKakaoAccessToken(String authorizationCode); - Member getRegisterUserInfo(String accesstoken, Member member); + String getOauthAccessToken(SnsProvider snsProvider, String authorizationCode); - Member getLoginUserInfo(String accesstoken); + Member getKakaoRegisterUserInfo(String accessToken, Member member); + + Member getOauthRegisterUserInfo(String accessToken, Member member); + + Member getLoginUserInfo(String accessToken); + + Member getOauthLoginUserInfo(SnsProvider snsProvider, String accessToken); } diff --git a/usecase/src/main/java/org/depromeet/spot/usecase/service/member/MemberService.java b/usecase/src/main/java/org/depromeet/spot/usecase/service/member/MemberService.java index a33f48c7..e2c5ec3c 100644 --- a/usecase/src/main/java/org/depromeet/spot/usecase/service/member/MemberService.java +++ b/usecase/src/main/java/org/depromeet/spot/usecase/service/member/MemberService.java @@ -37,7 +37,7 @@ public Member create(String accessToken, Member member) { if (memberRepository.existsByNickname(member.getNickname())) { throw new MemberNicknameConflictException(); } - Member memberResult = oauthRepository.getRegisterUserInfo(accessToken, member); + Member memberResult = oauthRepository.getKakaoRegisterUserInfo(accessToken, member); Level initialLevel = readLevelUsecase.findInitialLevel(); // 이미 있는 유저를 검증할 필요 없음 -> 최초 시도가 로그인먼저 들어오기 때문. return memberRepository.save(memberResult, initialLevel); diff --git a/usecase/src/main/java/org/depromeet/spot/usecase/service/oauth/OauthService.java b/usecase/src/main/java/org/depromeet/spot/usecase/service/oauth/OauthService.java new file mode 100644 index 00000000..3a18126e --- /dev/null +++ b/usecase/src/main/java/org/depromeet/spot/usecase/service/oauth/OauthService.java @@ -0,0 +1,62 @@ +package org.depromeet.spot.usecase.service.oauth; + +import org.depromeet.spot.common.exception.member.MemberException.InactiveMemberException; +import org.depromeet.spot.common.exception.member.MemberException.MemberNicknameConflictException; +import org.depromeet.spot.domain.member.Level; +import org.depromeet.spot.domain.member.Member; +import org.depromeet.spot.domain.member.enums.SnsProvider; +import org.depromeet.spot.usecase.port.in.member.level.ReadLevelUsecase; +import org.depromeet.spot.usecase.port.in.oauth.OauthUsecase; +import org.depromeet.spot.usecase.port.out.member.MemberRepository; +import org.depromeet.spot.usecase.port.out.oauth.OauthRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +@Service +@Transactional +@RequiredArgsConstructor +public class OauthService implements OauthUsecase { + + private final OauthRepository oauthRepository; + private final MemberRepository memberRepository; + private final ReadLevelUsecase readLevelUsecase; + + @Override + public Member create(String accessToken, Member member) { + if (memberRepository.existsByNickname(member.getNickname())) { + throw new MemberNicknameConflictException(); + } + Member memberResult = oauthRepository.getOauthRegisterUserInfo(accessToken, member); + Level initialLevel = readLevelUsecase.findInitialLevel(); + + return memberRepository.save(memberResult, initialLevel); + } + + @Override + public Member login(SnsProvider snsProvider, String token) { + String accessToken; + switch (snsProvider) { + case KAKAO: + accessToken = token; + break; + default: + accessToken = oauthRepository.getOauthAccessToken(snsProvider, token); + break; + } + Member memberResult = oauthRepository.getOauthLoginUserInfo(snsProvider, accessToken); + Member existedMember = memberRepository.findByIdToken(memberResult.getIdToken()); + + // 회원 탈퇴 유저일 경우 재가입 + if (existedMember.getDeletedAt() != null) { + throw new InactiveMemberException(); + } + return existedMember; + } + + @Override + public String getOauthAccessToken(SnsProvider snsProvider, String authorizationCode) { + return oauthRepository.getOauthAccessToken(snsProvider, authorizationCode); + } +} diff --git a/usecase/src/main/java/org/depromeet/spot/usecase/service/review/ReadReviewService.java b/usecase/src/main/java/org/depromeet/spot/usecase/service/review/ReadReviewService.java index 520f2f60..441978d4 100644 --- a/usecase/src/main/java/org/depromeet/spot/usecase/service/review/ReadReviewService.java +++ b/usecase/src/main/java/org/depromeet/spot/usecase/service/review/ReadReviewService.java @@ -29,9 +29,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import lombok.Builder; import lombok.RequiredArgsConstructor; @Service +@Builder @RequiredArgsConstructor @Transactional(readOnly = true) public class ReadReviewService implements ReadReviewUsecase { diff --git a/usecase/src/main/java/org/depromeet/spot/usecase/service/review/UpdateReviewService.java b/usecase/src/main/java/org/depromeet/spot/usecase/service/review/UpdateReviewService.java index 7d9d0e9e..44c58844 100644 --- a/usecase/src/main/java/org/depromeet/spot/usecase/service/review/UpdateReviewService.java +++ b/usecase/src/main/java/org/depromeet/spot/usecase/service/review/UpdateReviewService.java @@ -21,10 +21,12 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import lombok.Builder; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @Slf4j +@Builder @Service @RequiredArgsConstructor @Transactional diff --git a/usecase/src/main/java/org/depromeet/spot/usecase/service/review/like/ReviewLikeService.java b/usecase/src/main/java/org/depromeet/spot/usecase/service/review/like/ReviewLikeService.java index e88ebc68..2326fd7f 100644 --- a/usecase/src/main/java/org/depromeet/spot/usecase/service/review/like/ReviewLikeService.java +++ b/usecase/src/main/java/org/depromeet/spot/usecase/service/review/like/ReviewLikeService.java @@ -1,5 +1,6 @@ package org.depromeet.spot.usecase.service.review.like; +import org.depromeet.spot.common.annotation.DistributedLock; import org.depromeet.spot.domain.review.Review; import org.depromeet.spot.domain.review.like.ReviewLike; import org.depromeet.spot.usecase.port.in.review.ReadReviewUsecase; @@ -11,9 +12,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import lombok.Builder; import lombok.RequiredArgsConstructor; @Service +@Builder @Transactional @RequiredArgsConstructor public class ReviewLikeService implements ReviewLikeUsecase { @@ -23,9 +26,9 @@ public class ReviewLikeService implements ReviewLikeUsecase { private final ReviewLikeRepository reviewLikeRepository; private final MixpanelUtil mixpanelUtil; - // TODO: 분산락 적용 예정 @Override - public void toggleLike(final long memberId, final long reviewId) { + @DistributedLock(key = "#reviewId") + public void toggleLike(final Long memberId, final long reviewId) { Review review = readReviewUsecase.findById(reviewId); if (reviewLikeRepository.existsBy(memberId, reviewId)) { diff --git a/usecase/src/main/java/org/depromeet/spot/usecase/service/review/processor/ReviewCreationProcessorImpl.java b/usecase/src/main/java/org/depromeet/spot/usecase/service/review/processor/ReviewCreationProcessorImpl.java index b398e9bd..e1ddb0a9 100644 --- a/usecase/src/main/java/org/depromeet/spot/usecase/service/review/processor/ReviewCreationProcessorImpl.java +++ b/usecase/src/main/java/org/depromeet/spot/usecase/service/review/processor/ReviewCreationProcessorImpl.java @@ -84,6 +84,7 @@ public Review createAdminReview( .seat(seat) .dateTime(command.dateTime()) .content(command.content()) + .reviewType(ReviewType.VIEW) .build(); } } diff --git a/usecase/src/test/java/org/depromeet/spot/usecase/service/common/ConcurrencyTest.java b/usecase/src/test/java/org/depromeet/spot/usecase/service/common/ConcurrencyTest.java new file mode 100644 index 00000000..3ade3431 --- /dev/null +++ b/usecase/src/test/java/org/depromeet/spot/usecase/service/common/ConcurrencyTest.java @@ -0,0 +1,47 @@ +package org.depromeet.spot.usecase.service.common; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicLong; + +import org.junit.jupiter.api.function.Executable; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class ConcurrencyTest { + + private final int numberOfThread; + + private ExecutorService executorService; + private CountDownLatch latch; + + public ConcurrencyTest(final int numberOfThread) { + this.numberOfThread = numberOfThread; + } + + public AtomicLong execute(Executable executable) throws InterruptedException { + this.executorService = Executors.newFixedThreadPool(numberOfThread); + this.latch = new CountDownLatch(numberOfThread); + + AtomicLong exceptionCount = new AtomicLong(); + for (int i = 0; i < numberOfThread; i++) { + executorService.execute( + () -> { + try { + executable.execute(); + log.info("Thread " + Thread.currentThread().getId() + " - 성공"); + } catch (Throwable e) { + exceptionCount.getAndIncrement(); + log.info("Thread " + Thread.currentThread().getId() + " - 실패"); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executorService.shutdown(); + return exceptionCount; + } +} diff --git a/versions.properties b/versions.properties index 631f22e3..54bba2c9 100644 --- a/versions.properties +++ b/versions.properties @@ -60,3 +60,13 @@ version.com.github.loki4j..loki-logback-appender=1.4.2 version.io.micrometer..micrometer-registry-prometheus=1.12.4 version.com.github.ben-manes.caffeine..caffeine=3.1.8 + +version.it.ozimov..embedded-redis=0.7.3 + +version.org.testcontainers..testcontainers=1.20.1 + +version.org.testcontainers..junit-jupiter=1.20.1 + +version.org.testcontainers..mysql=1.20.1 + +version.org.testcontainers..jdbc=1.20.1