diff --git a/.gitignore b/.gitignore index 36ee796..c9fd7ab 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ build/ !**/src/main/**/build/ !**/src/test/**/build/ application-secret.yaml +firebase-service-key.json ### STS ### .apt_generated diff --git a/build.gradle.kts b/build.gradle.kts index c3ebfcb..3a2a8b9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -68,6 +68,10 @@ dependencies { // random implementation("org.apache.commons:commons-lang3:3.12.0") + + // notification + implementation("com.google.firebase:firebase-admin:9.2.0") + implementation("com.squareup.okhttp3:okhttp:4.10.0") } diff --git a/src/main/kotlin/com/psr/psr/global/Constant.kt b/src/main/kotlin/com/psr/psr/global/Constant.kt index b09be4c..35ea754 100644 --- a/src/main/kotlin/com/psr/psr/global/Constant.kt +++ b/src/main/kotlin/com/psr/psr/global/Constant.kt @@ -58,4 +58,11 @@ class Constant { const val POPULAR = "인기순" } } + + class NotiSentence{ + companion object NotiSentence{ + const val NEW_ORDER_SENTENCE = "님의 요청을 확인해주세요!" + const val TWO_MONTH_ORDER_SENTENCE = "님의 요청 상태를 확인해주세요!" + } + } } \ No newline at end of file diff --git a/src/main/kotlin/com/psr/psr/notification/dto/Data.kt b/src/main/kotlin/com/psr/psr/notification/dto/Data.kt new file mode 100644 index 0000000..0e1308d --- /dev/null +++ b/src/main/kotlin/com/psr/psr/notification/dto/Data.kt @@ -0,0 +1,6 @@ +package com.psr.psr.notification.dto + +data class Data( + val relatedId: Long, + val notiType: String +) diff --git a/src/main/kotlin/com/psr/psr/notification/dto/FcmMessage.kt b/src/main/kotlin/com/psr/psr/notification/dto/FcmMessage.kt new file mode 100644 index 0000000..6f393ee --- /dev/null +++ b/src/main/kotlin/com/psr/psr/notification/dto/FcmMessage.kt @@ -0,0 +1,6 @@ +package com.psr.psr.notification.dto + +data class FcmMessage ( + val validate_only: Boolean, + val message: Message +) diff --git a/src/main/kotlin/com/psr/psr/notification/dto/Message.kt b/src/main/kotlin/com/psr/psr/notification/dto/Message.kt new file mode 100644 index 0000000..d973cf6 --- /dev/null +++ b/src/main/kotlin/com/psr/psr/notification/dto/Message.kt @@ -0,0 +1,7 @@ +package com.psr.psr.notification.dto + +data class Message( + val notification: Notification, + val token: String, + val data: Data +) diff --git a/src/main/kotlin/com/psr/psr/notification/dto/NotiAssembler.kt b/src/main/kotlin/com/psr/psr/notification/dto/NotiAssembler.kt new file mode 100644 index 0000000..bc5e7ba --- /dev/null +++ b/src/main/kotlin/com/psr/psr/notification/dto/NotiAssembler.kt @@ -0,0 +1,48 @@ +package com.psr.psr.notification.dto + +import com.psr.psr.notification.entity.NotificationType +import com.psr.psr.notification.entity.PushNotification +import com.psr.psr.user.entity.User +import org.springframework.stereotype.Component + + +@Component +class NotiAssembler { + fun toEntity(receiver: User, title: String, content: String, relatedId: Long, type: NotificationType): PushNotification { + return PushNotification( + user = receiver, + title = title, + content = content, + relatedId = relatedId, + type = type + ) + } + fun toMessageDTO(targetToken: String, title: String, body: String, relatedId: Long, notiType: String): Message { + return Message( + notification = toNotificationDTO(title, body), + token = targetToken, + data = toDataDTO(relatedId, notiType) + ) + } + + fun toNotificationDTO(title: String, body: String): Notification { + return Notification( + title = title, + body = body + ) + } + + fun toDataDTO(relatedId: Long, notiType: String): Data { + return Data( + relatedId = relatedId, + notiType = notiType + ) + } + + fun makeMessage(targetToken: String, title: String, body: String, relatedId: Long, notiType: String): FcmMessage { + return FcmMessage( + validate_only = false, + message = toMessageDTO(targetToken, title, body, relatedId, notiType) + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/psr/psr/notification/dto/Notification.kt b/src/main/kotlin/com/psr/psr/notification/dto/Notification.kt new file mode 100644 index 0000000..81f817a --- /dev/null +++ b/src/main/kotlin/com/psr/psr/notification/dto/Notification.kt @@ -0,0 +1,6 @@ +package com.psr.psr.notification.dto + +data class Notification( + val title: String, + val body: String +) diff --git a/src/main/kotlin/com/psr/psr/notification/entity/NotificationType.kt b/src/main/kotlin/com/psr/psr/notification/entity/NotificationType.kt new file mode 100644 index 0000000..a17644b --- /dev/null +++ b/src/main/kotlin/com/psr/psr/notification/entity/NotificationType.kt @@ -0,0 +1,8 @@ +package com.psr.psr.notification.entity + +enum class NotificationType { + NEW_ORDER, + CHANGED_ORDER_STATUS, + TWO_MONTH_ORDER, + CHAT +} \ No newline at end of file diff --git a/src/main/kotlin/com/psr/psr/notification/entity/Notification.kt b/src/main/kotlin/com/psr/psr/notification/entity/PushNotification.kt similarity index 68% rename from src/main/kotlin/com/psr/psr/notification/entity/Notification.kt rename to src/main/kotlin/com/psr/psr/notification/entity/PushNotification.kt index e85761f..943c351 100644 --- a/src/main/kotlin/com/psr/psr/notification/entity/Notification.kt +++ b/src/main/kotlin/com/psr/psr/notification/entity/PushNotification.kt @@ -6,9 +6,9 @@ import jakarta.persistence.* import org.jetbrains.annotations.NotNull @Entity -data class Notification( +data class PushNotification( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - var id: Long, + var id: Long? = null, @ManyToOne @JoinColumn(nullable = false, name = "user_id") @@ -19,6 +19,12 @@ data class Notification( var title: String, @NotNull - var content: String + var content: String, + + // 알림의 주체인 요청, 채팅 등의 ID + var relatedId: Long, + + @NotNull + var type: NotificationType ) : BaseEntity() diff --git a/src/main/kotlin/com/psr/psr/notification/repository/NotificationRepository.kt b/src/main/kotlin/com/psr/psr/notification/repository/NotificationRepository.kt index 9a4083b..23c916c 100644 --- a/src/main/kotlin/com/psr/psr/notification/repository/NotificationRepository.kt +++ b/src/main/kotlin/com/psr/psr/notification/repository/NotificationRepository.kt @@ -1,9 +1,9 @@ package com.psr.psr.notification.repository -import com.psr.psr.notification.entity.Notification +import com.psr.psr.notification.entity.PushNotification import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository @Repository -interface NotificationRepository: JpaRepository, NotificationCustom { +interface NotificationRepository: JpaRepository, NotificationCustom { } \ No newline at end of file diff --git a/src/main/kotlin/com/psr/psr/notification/repository/NotificationRepositoryImpl.kt b/src/main/kotlin/com/psr/psr/notification/repository/NotificationRepositoryImpl.kt index c437258..6dd0607 100644 --- a/src/main/kotlin/com/psr/psr/notification/repository/NotificationRepositoryImpl.kt +++ b/src/main/kotlin/com/psr/psr/notification/repository/NotificationRepositoryImpl.kt @@ -2,7 +2,7 @@ package com.psr.psr.notification.repository import com.psr.psr.notification.dto.NotiList import com.psr.psr.notification.dto.NotificationListRes -import com.psr.psr.notification.entity.QNotification.notification +import com.psr.psr.notification.entity.QPushNotification.pushNotification import com.psr.psr.user.entity.User import com.querydsl.core.group.GroupBy.groupBy import com.querydsl.core.group.GroupBy.list @@ -23,19 +23,19 @@ class NotificationRepositoryImpl( override fun findNotificationByUserGroupByDate(user: User, pageable: Pageable): Page { val formattedDate: StringTemplate = Expressions.stringTemplate( "DATE_FORMAT({0}, {1})", - notification.createdAt, + pushNotification.createdAt, ConstantImpl.create("%Y-%m-%d") ) val result = queryFactory - .selectFrom(notification) - .where(notification.user.eq(user)) - .orderBy(notification.id.desc()) + .selectFrom(pushNotification) + .where(pushNotification.user.eq(user)) + .orderBy(pushNotification.id.desc()) .offset(pageable.offset) .limit(pageable.pageSize.toLong()) .transform(groupBy(formattedDate) .list(Projections.constructor(NotificationListRes::class.java, formattedDate, - list(Projections.constructor(NotiList::class.java, notification.title, notification.content))))) + list(Projections.constructor(NotiList::class.java, pushNotification.title, pushNotification.content))))) return PageImpl(result, pageable, result.size.toLong()) } diff --git a/src/main/kotlin/com/psr/psr/notification/service/NotificationService.kt b/src/main/kotlin/com/psr/psr/notification/service/NotificationService.kt index 8861a44..2c8d291 100644 --- a/src/main/kotlin/com/psr/psr/notification/service/NotificationService.kt +++ b/src/main/kotlin/com/psr/psr/notification/service/NotificationService.kt @@ -1,17 +1,132 @@ package com.psr.psr.notification.service +import com.fasterxml.jackson.databind.ObjectMapper +import com.google.auth.oauth2.GoogleCredentials +import com.psr.psr.global.Constant.NotiSentence.NotiSentence.NEW_ORDER_SENTENCE +import com.psr.psr.global.Constant.NotiSentence.NotiSentence.TWO_MONTH_ORDER_SENTENCE +import com.psr.psr.notification.dto.FcmMessage +import com.psr.psr.notification.dto.NotiAssembler import com.psr.psr.notification.dto.NotificationListRes +import com.psr.psr.notification.entity.NotificationType import com.psr.psr.notification.repository.NotificationRepository +import com.psr.psr.order.entity.OrderStatus import com.psr.psr.user.entity.User +import okhttp3.* +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody +import org.springframework.beans.factory.annotation.Value +import org.springframework.core.io.ClassPathResource import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable +import org.springframework.http.HttpHeaders import org.springframework.stereotype.Service @Service class NotificationService( - private val notificationRepository: NotificationRepository + private val notificationRepository: NotificationRepository, + private val notiAssembler: NotiAssembler, + @Value("\${firebase.sendUrl}") private val sendUrl: String, + private val objectMapper: ObjectMapper ) { + // 알림 목록 조회 fun getNotiList(user: User, pageable: Pageable): Page { return notificationRepository.findNotificationByUserGroupByDate(user, pageable) } + + // 새로운 요청 알림 + fun sendNewOrderNoti(productName: String, orderReceiver: User, ordererName: String, orderId: Long) { + val messageBody = ordererName + NEW_ORDER_SENTENCE + notificationRepository.save(notiAssembler.toEntity( + orderReceiver, + productName, + messageBody, + orderId, + NotificationType.NEW_ORDER + )) + + if (isPushNotiAvailable(orderReceiver)) { + val message: FcmMessage = notiAssembler.makeMessage( + orderReceiver.deviceToken!!, + productName, + messageBody, + orderId, + NotificationType.NEW_ORDER.name + ) + sendMessage(objectMapper.writeValueAsString(message)) + } + } + + // 요청 상태 변경 알림 + fun sendChangeOrderStatusNoti(productName: String, orderer: User, orderStatus: OrderStatus, orderId: Long) { + val messageBody = orderStatus.notiSentence!! + notificationRepository.save(notiAssembler.toEntity( + orderer, + productName, + messageBody, + orderId, + NotificationType.CHANGED_ORDER_STATUS + )) + + if (isPushNotiAvailable(orderer)) { + val message: FcmMessage = notiAssembler.makeMessage( + orderer.deviceToken!!, + productName, + messageBody, + orderId, + NotificationType.CHANGED_ORDER_STATUS.name + ) + sendMessage(objectMapper.writeValueAsString(message)) + } + } + + // 2달 뒤 요청상태 입력 요망 알림 + fun send2MonthOrderNoti(productName: String, orderer: User, ordererName: String, orderId: Long) { + val messageBody = ordererName + TWO_MONTH_ORDER_SENTENCE + notificationRepository.save(notiAssembler.toEntity( + orderer, + productName, + messageBody, + orderId, + NotificationType.TWO_MONTH_ORDER + )) + + if (isPushNotiAvailable(orderer)) { + val message: FcmMessage = notiAssembler.makeMessage( + orderer.deviceToken!!, + productName, + messageBody, + orderId, + NotificationType.TWO_MONTH_ORDER.name + ) + sendMessage(objectMapper.writeValueAsString(message)) + } + } + + // 알림 수신 상태 체크 + fun isPushNotiAvailable(user: User): Boolean { + return user.deviceToken != null && user.notification + } + + // firebase accessToken 발급 + private fun getAccessToken(): String? { + val firebaseConfigPath = "firebase-service-key.json" + val googleCredentials = GoogleCredentials + .fromStream(ClassPathResource(firebaseConfigPath).inputStream) + .createScoped(listOf("https://www.googleapis.com/auth/cloud-platform")) + googleCredentials.refreshIfExpired() + return googleCredentials.accessToken.tokenValue + } + + // 메세지 전송 + private fun sendMessage(message: String): Response { + val client = OkHttpClient() + val requestBody: RequestBody = message.toRequestBody("application/json; charset=utf-8".toMediaType()) + val request: Request = Request.Builder() + .url(sendUrl) + .post(requestBody) + .addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + getAccessToken()) + .addHeader(HttpHeaders.CONTENT_TYPE, "application/json; UTF-8") + .build() + return client.newCall(request).execute() + } } \ No newline at end of file diff --git a/src/main/kotlin/com/psr/psr/order/dto/OrderAssembler.kt b/src/main/kotlin/com/psr/psr/order/dto/OrderAssembler.kt index 98c4fc8..829edc8 100644 --- a/src/main/kotlin/com/psr/psr/order/dto/OrderAssembler.kt +++ b/src/main/kotlin/com/psr/psr/order/dto/OrderAssembler.kt @@ -23,7 +23,7 @@ class OrderAssembler { fun toOrderResDTO(order: Order, isSeller: Boolean): OrderRes { return OrderRes( isSeller = isSeller, - status = order.orderStatus.statusName, + status = order.orderStatus.value, orderUserId = order.user.id!!, orderDate = order.createdAt.format(DateTimeFormatter.ISO_DATE), productId = order.product.id!!, diff --git a/src/main/kotlin/com/psr/psr/order/entity/OrderStatus.kt b/src/main/kotlin/com/psr/psr/order/entity/OrderStatus.kt index aac58fa..e6728e9 100644 --- a/src/main/kotlin/com/psr/psr/order/entity/OrderStatus.kt +++ b/src/main/kotlin/com/psr/psr/order/entity/OrderStatus.kt @@ -2,16 +2,17 @@ package com.psr.psr.order.entity import com.psr.psr.global.exception.BaseException import com.psr.psr.global.exception.BaseResponseCode +import com.psr.psr.global.resolver.EnumType -enum class OrderStatus(val statusName: String) { - ORDER_WAITING("요청대기"), - PROGRESSING("진행중"), - COMPLETED("진행완료"), - CANCELED("요청취소"); +enum class OrderStatus(override val value: String, val notiSentence: String?): EnumType { + ORDER_WAITING("요청대기", null), + PROGRESSING("진행중", "요청이 진행되었습니다"), + COMPLETED("진행완료", "요청이 진행 완료되었습니다"), + CANCELED("요청취소", "요청이 취소되었습니다"); companion object { - fun findByName(statusName: String): OrderStatus { - return OrderStatus.values().find { it.statusName == statusName } + fun findByValue(value: String): OrderStatus { + return enumValues().find { it.value == value } ?: throw BaseException(BaseResponseCode.INVALID_ORDER_STATUS) } } diff --git a/src/main/kotlin/com/psr/psr/order/service/OrderService.kt b/src/main/kotlin/com/psr/psr/order/service/OrderService.kt index ece63a4..3626775 100644 --- a/src/main/kotlin/com/psr/psr/order/service/OrderService.kt +++ b/src/main/kotlin/com/psr/psr/order/service/OrderService.kt @@ -4,6 +4,7 @@ import com.psr.psr.global.Constant.OrderType.OrderType.SELL import com.psr.psr.global.Constant.UserStatus.UserStatus.ACTIVE_STATUS import com.psr.psr.global.exception.BaseException import com.psr.psr.global.exception.BaseResponseCode +import com.psr.psr.notification.service.NotificationService import com.psr.psr.order.dto.* import com.psr.psr.order.entity.Order import com.psr.psr.order.entity.OrderStatus @@ -19,13 +20,15 @@ import org.springframework.stereotype.Service class OrderService( private val orderRepository: OrderRepository, private val productRepository: ProductRepository, - private val orderAssembler: OrderAssembler + private val orderAssembler: OrderAssembler, + private val notificationService: NotificationService ) { // 요청하기 fun makeOrder(user: User, orderReq: OrderReq) { val product: Product = orderReq.productId?.let { productRepository.findByIdAndStatus(it, ACTIVE_STATUS) } ?: throw BaseException(BaseResponseCode.NOT_FOUND_PRODUCT) - orderRepository.save(orderAssembler.toEntity(user, orderReq, product)) + val order = orderRepository.save(orderAssembler.toEntity(user, orderReq, product)) + notificationService.sendNewOrderNoti(order.product.name, order.product.user, order.ordererName, order.id!!) } // 요청 상세 조회 @@ -49,7 +52,7 @@ class OrderService( // 요청 목록 조회(요청 상태별) fun getOrderListByOrderStatus(user: User, type: String, status: String, pageable: Pageable): Page { - val orderStatus = OrderStatus.findByName(status) + val orderStatus = OrderStatus.findByValue(status) val orderList: Page = if (type == SELL) orderRepository.findByProductUserAndOrderStatusAndStatus(user, orderStatus, ACTIVE_STATUS, pageable) @@ -65,9 +68,12 @@ class OrderService( if (order.user.id != user.id) throw BaseException(BaseResponseCode.NO_PERMISSION) var orderStatus: OrderStatus? = null - if (status != null) orderStatus = OrderStatus.findByName(status) + if (status != null) orderStatus = OrderStatus.findByValue(status) order.editOrder(orderReq, orderStatus) - orderRepository.save(order) + val saveOrder = orderRepository.save(order) + + if (status != null) + notificationService.sendChangeOrderStatusNoti(order.product.name, order.product.user, saveOrder.orderStatus, order.id!!) } } \ No newline at end of file diff --git a/src/main/kotlin/com/psr/psr/user/dto/assembler/UserAssembler.kt b/src/main/kotlin/com/psr/psr/user/dto/assembler/UserAssembler.kt index 3e92672..b842131 100644 --- a/src/main/kotlin/com/psr/psr/user/dto/assembler/UserAssembler.kt +++ b/src/main/kotlin/com/psr/psr/user/dto/assembler/UserAssembler.kt @@ -37,7 +37,8 @@ class UserAssembler { marketing = signUpReq.marketing, notification = signUpReq.notification, name = signUpReq.name, - nickname = signUpReq.nickname) + nickname = signUpReq.nickname, + deviceToken = signUpReq.deviceToken) } fun toInterestListEntity(user: User, signUpReq: SignUpReq): List { diff --git a/src/main/kotlin/com/psr/psr/user/dto/request/SignUpReq.kt b/src/main/kotlin/com/psr/psr/user/dto/request/SignUpReq.kt index 508c234..b787c8b 100644 --- a/src/main/kotlin/com/psr/psr/user/dto/request/SignUpReq.kt +++ b/src/main/kotlin/com/psr/psr/user/dto/request/SignUpReq.kt @@ -43,6 +43,7 @@ data class SignUpReq ( val marketing: Boolean, @field:NotNull val notification: Boolean, + val deviceToken: String? = null, @field:NotEmpty val interestList: List, val entreInfo: UserEidReq?= null diff --git a/src/main/kotlin/com/psr/psr/user/entity/User.kt b/src/main/kotlin/com/psr/psr/user/entity/User.kt index 57c2abe..74aa061 100644 --- a/src/main/kotlin/com/psr/psr/user/entity/User.kt +++ b/src/main/kotlin/com/psr/psr/user/entity/User.kt @@ -55,6 +55,8 @@ class User( @NotNull var notification: Boolean, + var deviceToken: String? = null, + @OneToMany(mappedBy = "user") @Where(clause = "status = 'active'") var products: List? = ArrayList(),