diff --git a/user-service/build.gradle.kts b/user-service/build.gradle.kts index 3cd1fdd..caa2d23 100644 --- a/user-service/build.gradle.kts +++ b/user-service/build.gradle.kts @@ -50,6 +50,9 @@ dependencies { implementation("io.micrometer:micrometer-tracing-bridge-otel:1.3.2") implementation("io.opentelemetry:opentelemetry-exporter-zipkin:1.40.0") implementation("io.micrometer:micrometer-registry-prometheus:1.13.2") + + implementation("com.slack.api:slack-api-client:1.40.3") + } tasks.withType().all { diff --git a/user-service/src/main/java/kr/mafoo/user/api/AuthApi.java b/user-service/src/main/java/kr/mafoo/user/api/AuthApi.java index 2fe2fd6..e097d8c 100644 --- a/user-service/src/main/java/kr/mafoo/user/api/AuthApi.java +++ b/user-service/src/main/java/kr/mafoo/user/api/AuthApi.java @@ -9,7 +9,9 @@ import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; @Tag(name = "인증(로그인) 관련 API", description = "토큰 발행, 로그인 등 API") @@ -19,13 +21,15 @@ public interface AuthApi { @Operation(summary = "카카오 로그인", description = "카카오 인가 코드로 로그인(토큰 발행)합니다.") @PostMapping("/login/kakao") Mono loginWithKakao( - @RequestBody KakaoLoginRequest request + @RequestBody KakaoLoginRequest request, + ServerWebExchange exchange ); @Operation(summary = "애플 로그인" , description = "애플 인가 코드로 로그인(토큰 발행)합니다.") @PostMapping("/login/apple") Mono loginWithApple( - @RequestBody AppleLoginRequest request + @RequestBody AppleLoginRequest request, + ServerWebExchange exchange ); @Operation(summary = "토큰 갱신", description = "리프레시 토큰으로 기존 토큰을 갱신합니다.") diff --git a/user-service/src/main/java/kr/mafoo/user/config/SlackApiConfig.java b/user-service/src/main/java/kr/mafoo/user/config/SlackApiConfig.java new file mode 100644 index 0000000..d82edd2 --- /dev/null +++ b/user-service/src/main/java/kr/mafoo/user/config/SlackApiConfig.java @@ -0,0 +1,20 @@ +package kr.mafoo.user.config; + +import com.slack.api.Slack; +import com.slack.api.methods.MethodsClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SlackApiConfig { + + @Value("${slack.webhook.token}") + private String token; + + @Bean + public MethodsClient getClient() { + Slack slackClient = Slack.getInstance(); + return slackClient.methods(token); + } +} \ No newline at end of file diff --git a/user-service/src/main/java/kr/mafoo/user/controller/AuthController.java b/user-service/src/main/java/kr/mafoo/user/controller/AuthController.java index 3c144a7..57eb2be 100644 --- a/user-service/src/main/java/kr/mafoo/user/controller/AuthController.java +++ b/user-service/src/main/java/kr/mafoo/user/controller/AuthController.java @@ -5,9 +5,11 @@ import kr.mafoo.user.controller.dto.request.KakaoLoginRequest; import kr.mafoo.user.controller.dto.request.TokenRefreshRequest; import kr.mafoo.user.controller.dto.response.LoginResponse; +import kr.mafoo.user.domain.AuthToken; import kr.mafoo.user.service.AuthService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; @RequiredArgsConstructor @@ -16,23 +18,34 @@ public class AuthController implements AuthApi { private final AuthService authService; @Override - public Mono loginWithKakao(KakaoLoginRequest request) { + public Mono loginWithKakao(KakaoLoginRequest request, ServerWebExchange exchange) { + String userAgent = getUserAgent(exchange); return authService - .loginWithKakao(request.accessToken()) - .map(authToken -> new LoginResponse(authToken.accessToken(), authToken.refreshToken())); + .loginWithKakao(request.accessToken(), userAgent) + .map(this::toLoginResponse); } @Override - public Mono loginWithApple(AppleLoginRequest request) { + public Mono loginWithApple(AppleLoginRequest request, ServerWebExchange exchange) { + String userAgent = getUserAgent(exchange); return authService - .loginWithApple(request.identityToken()) - .map(authToken -> new LoginResponse(authToken.accessToken(), authToken.refreshToken())); + .loginWithApple(request.identityToken(), userAgent) + .map(this::toLoginResponse); } @Override public Mono loginWithRefreshToken(TokenRefreshRequest request) { return authService .loginWithRefreshToken(request.refreshToken()) - .map(authToken -> new LoginResponse(authToken.accessToken(), authToken.refreshToken())); + .map(this::toLoginResponse); } + + private String getUserAgent(ServerWebExchange exchange) { + return exchange.getRequest().getHeaders().getFirst("User-Agent"); + } + + private LoginResponse toLoginResponse(AuthToken authToken) { + return new LoginResponse(authToken.accessToken(), authToken.refreshToken()); + } + } diff --git a/user-service/src/main/java/kr/mafoo/user/domain/MemberEntity.java b/user-service/src/main/java/kr/mafoo/user/domain/MemberEntity.java index 8f7c11e..fc1ee2b 100644 --- a/user-service/src/main/java/kr/mafoo/user/domain/MemberEntity.java +++ b/user-service/src/main/java/kr/mafoo/user/domain/MemberEntity.java @@ -3,7 +3,6 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.Id; import org.springframework.data.annotation.Transient; import org.springframework.data.domain.Persistable; @@ -24,7 +23,6 @@ public class MemberEntity implements Persistable { @Column("name") private String name; - @CreatedDate @Column("created_at") private LocalDateTime createdAt; @@ -54,6 +52,7 @@ public static MemberEntity newMember(String id, String name, String profileImage member.id = id; member.name = name; member.profileImageUrl = profileImageUrl; + member.createdAt = LocalDateTime.now(); member.isNew = true; return member; } diff --git a/user-service/src/main/java/kr/mafoo/user/service/AuthService.java b/user-service/src/main/java/kr/mafoo/user/service/AuthService.java index c5d4da4..15a2374 100644 --- a/user-service/src/main/java/kr/mafoo/user/service/AuthService.java +++ b/user-service/src/main/java/kr/mafoo/user/service/AuthService.java @@ -24,7 +24,6 @@ import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Mono; -import java.security.Key; import java.security.interfaces.RSAPublicKey; import java.util.Arrays; import java.util.Base64; @@ -42,25 +41,27 @@ public class AuthService { private final ObjectMapper objectMapper; @Transactional - public Mono loginWithKakao(String kakaoAccessToken) { + public Mono loginWithKakao(String kakaoAccessToken, String userAgent) { return getUserInfoWithKakaoToken(kakaoAccessToken) .flatMap(kakaoLoginInfo -> getOrCreateMember( IdentityProvider.KAKAO, kakaoLoginInfo.id(), kakaoLoginInfo.nickname(), - kakaoLoginInfo.profileImageUrl() + kakaoLoginInfo.profileImageUrl(), + userAgent )); } @Transactional - public Mono loginWithApple(String identityToken) { + public Mono loginWithApple(String identityToken, String userAgent) { return getApplePublicKeys() .flatMap(keyObj -> getUserInfoWithAppleAccessToken(keyObj.keys(), identityToken)) .flatMap(appleLoginInfo -> getOrCreateMember( IdentityProvider.APPLE, appleLoginInfo.id(), NicknameGenerator.generate(), - null + null, + userAgent )); } @@ -75,10 +76,10 @@ public Mono loginWithRefreshToken(String refreshToken){ }); } - private Mono getOrCreateMember(IdentityProvider provider, String id, String username, String profileImageUrl) { + private Mono getOrCreateMember(IdentityProvider provider, String id, String username, String profileImageUrl, String userAgent) { return socialMemberRepository .findByIdentityProviderAndId(provider, id) - .switchIfEmpty(createNewSocialMember(provider, id, username, profileImageUrl)) + .switchIfEmpty(createNewSocialMember(provider, id, username, profileImageUrl, userAgent)) .map(socialMember -> { String accessToken = jwtTokenService.generateAccessToken(socialMember.getMemberId()); String refreshToken = jwtTokenService.generateRefreshToken(socialMember.getMemberId()); @@ -86,9 +87,9 @@ private Mono getOrCreateMember(IdentityProvider provider, String id, }); } - private Mono createNewSocialMember(IdentityProvider provider, String id, String username, String profileImageUrl) { + private Mono createNewSocialMember(IdentityProvider provider, String id, String username, String profileImageUrl, String userAgent) { return memberService - .createNewMember(username, profileImageUrl) + .createNewMember(username, profileImageUrl, userAgent) .flatMap(newMember -> socialMemberRepository.save( SocialMemberEntity.newSocialMember(provider, id, newMember.getId()) )); diff --git a/user-service/src/main/java/kr/mafoo/user/service/MemberService.java b/user-service/src/main/java/kr/mafoo/user/service/MemberService.java index c157994..63af4cb 100644 --- a/user-service/src/main/java/kr/mafoo/user/service/MemberService.java +++ b/user-service/src/main/java/kr/mafoo/user/service/MemberService.java @@ -5,14 +5,17 @@ import kr.mafoo.user.repository.MemberRepository; import kr.mafoo.user.util.IdGenerator; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import reactor.core.publisher.Mono; +@Slf4j @RequiredArgsConstructor @Service public class MemberService { private final MemberRepository memberRepository; + private final SlackService slackService; public Mono quitMemberByMemberId(String memberId) { return memberRepository.deleteMemberById(memberId); @@ -25,8 +28,19 @@ public Mono getMemberByMemberId(String memberId) { } @Transactional - public Mono createNewMember(String username, String profileImageUrl) { + public Mono createNewMember(String username, String profileImageUrl, String userAgent) { MemberEntity memberEntity = MemberEntity.newMember(IdGenerator.generate(), username, profileImageUrl); - return memberRepository.save(memberEntity); + + return memberRepository.save(memberEntity) + .flatMap(savedMember -> + slackService.sendNewMemberNotification( + memberEntity.getId(), + memberEntity.getName(), + memberEntity.getProfileImageUrl(), + memberEntity.getCreatedAt().toString(), + userAgent + ) + .then(Mono.just(savedMember)) + ); } } diff --git a/user-service/src/main/java/kr/mafoo/user/service/SlackService.java b/user-service/src/main/java/kr/mafoo/user/service/SlackService.java new file mode 100644 index 0000000..81f10d4 --- /dev/null +++ b/user-service/src/main/java/kr/mafoo/user/service/SlackService.java @@ -0,0 +1,117 @@ +package kr.mafoo.user.service; + +import com.slack.api.methods.MethodsClient; +import com.slack.api.methods.SlackApiException; +import com.slack.api.methods.request.chat.ChatPostMessageRequest; +import com.slack.api.model.block.Blocks; +import com.slack.api.model.block.LayoutBlock; +import com.slack.api.model.block.composition.MarkdownTextObject; +import com.slack.api.model.block.composition.TextObject; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import static com.slack.api.model.block.Blocks.*; +import static com.slack.api.model.block.composition.BlockCompositions.markdownText; +import static com.slack.api.model.block.composition.BlockCompositions.plainText; + +@Service +@RequiredArgsConstructor +public class SlackService { + + @Value(value = "${slack.webhook.channel.error}") + private String errorChannel; + + @Value(value = "${slack.webhook.channel.member}") + private String memberChannel; + + private final MethodsClient methodsClient; + + public void sendErrorNotification(Throwable throwable, String method, String uri, String statusCode, long executionTime, String userAgent) { + try { + List textObjects = new ArrayList<>(); + + textObjects.add(markdownText(">*예상하지 못한 에러가 발생했습니다!*\n")); + textObjects.add(markdownText("\n")); + + textObjects.add(markdownText("*메소드:* \n`" + method + "`\n")); + textObjects.add(markdownText("*URI:* \n`" + uri + "`\n")); + textObjects.add(markdownText("*상태코드:* \n`" + statusCode + "`\n")); + textObjects.add(markdownText("*메세지:* \n`" + throwable.getMessage() + "`\n")); + textObjects.add(markdownText("*소요시간:* \n`" + executionTime + " ms`\n")); + textObjects.add(markdownText("*사용자:* \n`" + userAgent + "`\n")); + + ChatPostMessageRequest request = ChatPostMessageRequest + .builder() + .channel(errorChannel) + .blocks( + asBlocks( + divider(), + section( + section -> section.fields(textObjects) + ) + )) + .build(); + + methodsClient.chatPostMessage(request); + } catch (SlackApiException | IOException e) { + throw new RuntimeException("Can't send Slack Message.", e); + } + } + + public Mono sendNewMemberNotification(String memberId, String memberName, String memberProfileImageUrl, String memberCreatedAt, String userAgent) { + return Mono.fromCallable(() -> { + List layoutBlocks = new ArrayList<>(); + + layoutBlocks.add( + Blocks.header( + headerBlockBuilder -> + headerBlockBuilder.text(plainText("🎉 신규 사용자 가입")))); + layoutBlocks.add(divider()); + + MarkdownTextObject userIdMarkdown = + MarkdownTextObject.builder().text("`사용자 ID`\n" + memberId).build(); + + MarkdownTextObject userNameMarkdown = + MarkdownTextObject.builder().text("`사용자 닉네임`\n" + memberName).build(); + + layoutBlocks.add( + section( + section -> section.fields(List.of(userIdMarkdown, userNameMarkdown)))); + + MarkdownTextObject userProfileImageMarkdown = + MarkdownTextObject.builder().text("`프로필 이미지`\n" + memberProfileImageUrl).build(); + + MarkdownTextObject userCreatedAtMarkdown = + MarkdownTextObject.builder().text("`가입 일자`\n" + memberCreatedAt).build(); + + layoutBlocks.add( + section( + section -> section.fields(List.of(userProfileImageMarkdown, userCreatedAtMarkdown)))); + + MarkdownTextObject userUserAgentMarkdown = + MarkdownTextObject.builder().text("`가입 환경`\n" + userAgent).build(); + + layoutBlocks.add( + section( + section -> section.fields(List.of(userUserAgentMarkdown)))); + + ChatPostMessageRequest chatPostMessageRequest = + ChatPostMessageRequest + .builder() + .text("신규 사용자 가입 알림") + .channel(memberChannel) + .blocks(layoutBlocks) + .build(); + + return methodsClient.chatPostMessage(chatPostMessageRequest); + }) + .then(); + } + +} diff --git a/user-service/src/main/resources/application.yaml b/user-service/src/main/resources/application.yaml index 951efb5..ce6440d 100644 --- a/user-service/src/main/resources/application.yaml +++ b/user-service/src/main/resources/application.yaml @@ -43,3 +43,10 @@ management: zipkin: tracing: endpoint: http://zipkin/api/v2/spans + +slack: + webhook: + token: ${SLACK_TOKEN} + channel: + error: ${SLACK_ERROR_CHANNEL} + member: ${SLACK_MEMBER_CHANNEL}