diff --git a/src/main/kotlin/com/weeth/WeethApplication.kt b/src/main/kotlin/com/weeth/WeethApplication.kt index 8664ed91..55d667ba 100644 --- a/src/main/kotlin/com/weeth/WeethApplication.kt +++ b/src/main/kotlin/com/weeth/WeethApplication.kt @@ -4,9 +4,11 @@ import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication import org.springframework.data.jpa.repository.config.EnableJpaAuditing +import org.springframework.scheduling.annotation.EnableAsync import org.springframework.scheduling.annotation.EnableScheduling import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +@EnableAsync @EnableScheduling @EnableJpaAuditing @EnableWebSecurity diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/request/CreateInquiryRequest.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/request/CreateInquiryRequest.kt new file mode 100644 index 00000000..0a260414 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/request/CreateInquiryRequest.kt @@ -0,0 +1,18 @@ +package com.weeth.domain.user.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size + +data class CreateInquiryRequest( + @field:Schema(description = "이메일", example = "user@example.com") + @field:NotBlank + @field:Email + @field:Size(max = 255) + val email: String, + @field:Schema(description = "문의 내용", example = "서비스에 대해 문의드립니다.") + @field:NotBlank + @field:Size(max = 1000) + val message: String, +) diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/CreateInquiryUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/CreateInquiryUseCase.kt new file mode 100644 index 00000000..bd1c3c41 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/CreateInquiryUseCase.kt @@ -0,0 +1,17 @@ +package com.weeth.domain.user.application.usecase.command + +import com.weeth.domain.user.application.dto.request.CreateInquiryRequest +import com.weeth.domain.user.domain.port.InquiryNotifyPort +import com.weeth.domain.user.domain.port.InquirySavePort +import org.springframework.stereotype.Service + +@Service +class CreateInquiryUseCase( + private val inquirySavePort: InquirySavePort, + private val inquiryNotifyPort: InquiryNotifyPort, +) { + fun execute(request: CreateInquiryRequest) { + inquirySavePort.save(request.email, request.message) + inquiryNotifyPort.notify(request.email, request.message) + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/port/InquiryNotifyPort.kt b/src/main/kotlin/com/weeth/domain/user/domain/port/InquiryNotifyPort.kt new file mode 100644 index 00000000..0acd48c4 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/port/InquiryNotifyPort.kt @@ -0,0 +1,8 @@ +package com.weeth.domain.user.domain.port + +interface InquiryNotifyPort { + fun notify( + email: String, + message: String, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/port/InquirySavePort.kt b/src/main/kotlin/com/weeth/domain/user/domain/port/InquirySavePort.kt new file mode 100644 index 00000000..5d794af6 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/port/InquirySavePort.kt @@ -0,0 +1,8 @@ +package com.weeth.domain.user.domain.port + +interface InquirySavePort { + fun save( + email: String, + message: String, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/user/infrastructure/NotionInquirySaveAdapter.kt b/src/main/kotlin/com/weeth/domain/user/infrastructure/NotionInquirySaveAdapter.kt new file mode 100644 index 00000000..c2ca6cb0 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/infrastructure/NotionInquirySaveAdapter.kt @@ -0,0 +1,61 @@ +package com.weeth.domain.user.infrastructure + +import com.weeth.domain.user.domain.port.InquirySavePort +import com.weeth.global.config.properties.NotionProperties +import org.slf4j.LoggerFactory +import org.springframework.http.HttpHeaders +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Component +import org.springframework.web.client.RestClient +import java.time.LocalDate + +@Component +class NotionInquirySaveAdapter( + private val notionProperties: NotionProperties, + restClientBuilder: RestClient.Builder, +) : InquirySavePort { + private val restClient = restClientBuilder.baseUrl("https://api.notion.com").build() + private val log = LoggerFactory.getLogger(javaClass) + + @Async + override fun save( + email: String, + message: String, + ) { + val body = + mapOf( + "parent" to + mapOf( + "type" to "database_id", + "database_id" to notionProperties.inquiryDatabaseId, + ), + "properties" to + mapOf( + "문의내용" to + mapOf( + "title" to listOf(mapOf("text" to mapOf("content" to message))), + ), + "이메일" to + mapOf( + "email" to email, + ), + "날짜" to + mapOf( + "date" to mapOf("start" to LocalDate.now().toString()), + ), + ), + ) + + runCatching { + restClient + .post() + .uri("/v1/pages") + .header(HttpHeaders.AUTHORIZATION, "Bearer ${notionProperties.token}") + .header("Notion-Version", notionProperties.version) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .body(body) + .retrieve() + .toBodilessEntity() + }.onFailure { e -> log.warn("Notion 저장 실패: {}", e.message) } + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/infrastructure/SlackInquiryNotifyAdapter.kt b/src/main/kotlin/com/weeth/domain/user/infrastructure/SlackInquiryNotifyAdapter.kt new file mode 100644 index 00000000..25488bc1 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/infrastructure/SlackInquiryNotifyAdapter.kt @@ -0,0 +1,35 @@ +package com.weeth.domain.user.infrastructure + +import com.weeth.domain.user.domain.port.InquiryNotifyPort +import com.weeth.global.config.properties.SlackProperties +import org.slf4j.LoggerFactory +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Component +import org.springframework.web.client.RestClient + +@Component +class SlackInquiryNotifyAdapter( + private val slackProperties: SlackProperties, + restClientBuilder: RestClient.Builder, +) : InquiryNotifyPort { + private val restClient = restClientBuilder.build() + private val log = LoggerFactory.getLogger(javaClass) + + @Async + override fun notify( + email: String, + message: String, + ) { + val text = "*[랜딩 문의하기]*\n*이메일:* $email\n*문의 내용:*\n```$message```" + + runCatching { + restClient + .post() + .uri(slackProperties.webhookUrl) + .header("Content-Type", "application/json") + .body(mapOf("text" to text)) + .retrieve() + .toBodilessEntity() + }.onFailure { e -> log.warn("Slack 알림 전송 실패: {}", e.message) } + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt b/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt index f46018e3..94f56fe6 100644 --- a/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt +++ b/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt @@ -1,12 +1,14 @@ package com.weeth.domain.user.presentation import com.weeth.domain.user.application.dto.request.AgreeTermsRequest +import com.weeth.domain.user.application.dto.request.CreateInquiryRequest import com.weeth.domain.user.application.dto.request.SocialLoginRequest import com.weeth.domain.user.application.dto.request.UpdateUserProfileRequest import com.weeth.domain.user.application.dto.response.SocialLoginResponse import com.weeth.domain.user.application.exception.UserErrorCode import com.weeth.domain.user.application.usecase.command.AgreeTermsUseCase import com.weeth.domain.user.application.usecase.command.AuthUserUseCase +import com.weeth.domain.user.application.usecase.command.CreateInquiryUseCase import com.weeth.domain.user.application.usecase.command.SocialLoginUseCase import com.weeth.domain.user.application.usecase.command.UpdateUserProfileUseCase import com.weeth.global.auth.annotation.CurrentUser @@ -38,6 +40,7 @@ class UserController( private val socialLoginUseCase: SocialLoginUseCase, private val updateUserProfileUseCase: UpdateUserProfileUseCase, private val agreeTermsUseCase: AgreeTermsUseCase, + private val createInquiryUseCase: CreateInquiryUseCase, private val tokenCookieProvider: TokenCookieProvider, ) { @PostMapping("/social/kakao") @@ -104,6 +107,16 @@ class UserController( return CommonResponse.success(UserResponseCode.USER_UPDATE_SUCCESS) } + @PostMapping("/inquiries") + @Operation(summary = "문의하기") + @SecurityRequirements + fun createInquiry( + @RequestBody @Valid request: CreateInquiryRequest, + ): CommonResponse { + createInquiryUseCase.execute(request) + return CommonResponse.success(UserResponseCode.INQUIRY_SEND_SUCCESS) + } + private fun buildTokenResponse( body: CommonResponse, accessToken: String, diff --git a/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt b/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt index c0a186ba..c1776a65 100644 --- a/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt @@ -12,4 +12,5 @@ enum class UserResponseCode( JWT_REFRESH_SUCCESS(10902, HttpStatus.OK, "토큰 재발급에 성공했습니다."), SOCIAL_LOGIN_SUCCESS(10903, HttpStatus.OK, "소셜 로그인이 성공적으로 처리되었습니다."), USER_TERMS_AGREE_SUCCESS(10904, HttpStatus.OK, "약관 동의가 성공적으로 처리되었습니다."), + INQUIRY_SEND_SUCCESS(10905, HttpStatus.OK, "문의가 성공적으로 접수되었습니다."), } diff --git a/src/main/kotlin/com/weeth/global/config/AsyncConfig.kt b/src/main/kotlin/com/weeth/global/config/AsyncConfig.kt new file mode 100644 index 00000000..c9fffaa9 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/config/AsyncConfig.kt @@ -0,0 +1,19 @@ +package com.weeth.global.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor +import java.util.concurrent.Executor + +@Configuration +class AsyncConfig { + @Bean(name = ["taskExecutor"]) + fun taskExecutor(): Executor = + ThreadPoolTaskExecutor().apply { + corePoolSize = 5 + maxPoolSize = 10 + queueCapacity = 50 + setThreadNamePrefix("async-") + initialize() + } +} diff --git a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt index d1c02a2b..33a27fef 100644 --- a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt +++ b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt @@ -45,6 +45,7 @@ class SecurityConfig( "/api/v4/users/social/kakao", "/api/v4/users/social/apple", "/api/v4/users/social/refresh", + "/api/v4/users/inquiries", ).permitAll() .requestMatchers("/health-check") .permitAll() diff --git a/src/main/kotlin/com/weeth/global/config/properties/NotionProperties.kt b/src/main/kotlin/com/weeth/global/config/properties/NotionProperties.kt new file mode 100644 index 00000000..e0c10158 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/config/properties/NotionProperties.kt @@ -0,0 +1,13 @@ +package com.weeth.global.config.properties + +import jakarta.validation.constraints.NotBlank +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.validation.annotation.Validated + +@Validated +@ConfigurationProperties(prefix = "notion") +data class NotionProperties( + @field:NotBlank val token: String, + @field:NotBlank val version: String, + @field:NotBlank val inquiryDatabaseId: String, +) diff --git a/src/main/kotlin/com/weeth/global/config/properties/SlackProperties.kt b/src/main/kotlin/com/weeth/global/config/properties/SlackProperties.kt new file mode 100644 index 00000000..a90636ac --- /dev/null +++ b/src/main/kotlin/com/weeth/global/config/properties/SlackProperties.kt @@ -0,0 +1,11 @@ +package com.weeth.global.config.properties + +import jakarta.validation.constraints.NotBlank +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.validation.annotation.Validated + +@Validated +@ConfigurationProperties(prefix = "slack") +data class SlackProperties( + @field:NotBlank val webhookUrl: String, +) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 535ad46d..d955eb5e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -49,3 +49,11 @@ app: career-net: key: ${CAREER_NET_API_KEY} base-url: https://www.career.go.kr/cnet/openapi/getOpenApi + +slack: + webhook-url: ${SLACK_WEBHOOK_URL} + +notion: + token: ${NOTION_TOKEN} + version: ${NOTION_VERSION} + inquiry-database-id: ${NOTION_INQUIRY_DATABASE_ID}