Merge pull request '유저 행동 데이터, 포인트 추가' (#309) from test into main

Reviewed-on: #309
This commit is contained in:
klaus 2025-04-24 02:44:57 +00:00
commit 3c087bc275
43 changed files with 804 additions and 30 deletions

View File

@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.admin.calculate
import com.querydsl.core.types.dsl.CaseBuilder
import com.querydsl.core.types.dsl.DateTimePath
import com.querydsl.core.types.dsl.Expressions
import com.querydsl.core.types.dsl.StringTemplate
@ -51,6 +52,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
fun getCalculateContentList(startDate: LocalDateTime, endDate: LocalDateTime): List<GetCalculateContentQueryData> {
val orderFormattedDate = getFormattedDate(order.createdAt)
val pointGroup = CaseBuilder()
.`when`(order.point.loe(0)).then(0)
.otherwise(1)
return queryFactory
.select(
QGetCalculateContentQueryData(
@ -62,6 +67,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
order.can,
order.id.count(),
order.can.sum(),
order.point.sum(),
creatorSettlementRatio.contentSettlementRatio
)
)
@ -80,6 +86,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
order.type,
orderFormattedDate,
order.can,
pointGroup,
creatorSettlementRatio.contentSettlementRatio
)
.orderBy(member.id.desc(), orderFormattedDate.desc(), audioContent.id.asc())
@ -113,6 +120,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
}
fun getCumulativeSalesByContent(offset: Long, limit: Long): List<GetCumulativeSalesByContentQueryData> {
val pointGroup = CaseBuilder()
.`when`(order.point.loe(0)).then(0)
.otherwise(1)
return queryFactory
.select(
QGetCumulativeSalesByContentQueryData(
@ -123,6 +134,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
order.can,
order.id.count(),
order.can.sum(),
order.point.sum(),
creatorSettlementRatio.contentSettlementRatio
)
)
@ -132,7 +144,14 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id))
.where(order.isActive.isTrue)
.groupBy(member.id, audioContent.id, order.type, order.can)
.groupBy(
member.id,
audioContent.id,
order.type,
order.can,
pointGroup,
creatorSettlementRatio.contentSettlementRatio
)
.offset(offset)
.limit(limit)
.orderBy(member.id.desc(), audioContent.id.desc())

View File

@ -22,11 +22,15 @@ data class GetCalculateContentQueryData @QueryProjection constructor(
val numberOfPeople: Long,
// 합계
val totalCan: Int,
// 포인트
val totalPoint: Int,
// 정산비율
val settlementRatio: Int?
) {
fun toGetCalculateContentResponse(): GetCalculateContentResponse {
val orderTypeStr = if (orderType == OrderType.RENTAL) {
val orderTypeStr = if (totalPoint > 0) {
"포인트"
} else if (orderType == OrderType.RENTAL) {
"대여"
} else {
"소장"

View File

@ -21,11 +21,15 @@ data class GetCumulativeSalesByContentQueryData @QueryProjection constructor(
val numberOfPeople: Long,
// 합계
val totalCan: Int,
// 포인트
val totalPoint: Int,
// 정산비율
val settlementRatio: Int?
) {
fun toCumulativeSalesByContentItem(): CumulativeSalesByContentItem {
val orderTypeStr = if (orderType == OrderType.RENTAL) {
val orderTypeStr = if (totalPoint > 0) {
"포인트"
} else if (orderType == OrderType.RENTAL) {
"대여"
} else {
"소장"

View File

@ -0,0 +1,40 @@
package kr.co.vividnext.sodalive.admin.point
import kr.co.vividnext.sodalive.point.PointRewardPolicy
import kr.co.vividnext.sodalive.useraction.ActionType
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
data class CreatePointRewardPolicyRequest(
val title: String,
val actionType: ActionType,
val threshold: Int,
val pointAmount: Int,
val startDate: String,
val endDate: String
) {
fun toEntity(): PointRewardPolicy {
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
return PointRewardPolicy(
title = title,
actionType = actionType,
threshold = threshold,
pointAmount = pointAmount,
startDate = LocalDateTime.parse(startDate, dateTimeFormatter)
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime(),
endDate = if (endDate.isNotBlank()) {
LocalDateTime.parse(endDate, dateTimeFormatter).withSecond(59)
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
} else {
null
},
isActive = true
)
}
}

View File

@ -0,0 +1,20 @@
package kr.co.vividnext.sodalive.admin.point
import com.querydsl.core.annotations.QueryProjection
import kr.co.vividnext.sodalive.useraction.ActionType
data class GetPointRewardPolicyListResponse(
val totalCount: Int,
val items: List<GetPointRewardPolicyListItem>
)
data class GetPointRewardPolicyListItem @QueryProjection constructor(
val id: Long,
val title: String,
val actionType: ActionType,
val threshold: Int,
val pointAmount: Int,
val startDate: String,
val endDate: String,
val isActive: Boolean
)

View File

@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.admin.point
data class ModifyPointRewardPolicyRequest(
val title: String?,
val startDate: String?,
val endDate: String?,
val isActive: Boolean?
)

View File

@ -0,0 +1,36 @@
package kr.co.vividnext.sodalive.admin.point
import kr.co.vividnext.sodalive.common.ApiResponse
import org.springframework.data.domain.Pageable
import org.springframework.security.access.prepost.PreAuthorize
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.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/admin/point-policies")
@PreAuthorize("hasRole('ADMIN')")
class PointPolicyController(private val service: PointPolicyService) {
@GetMapping
fun getAll(pageable: Pageable) = ApiResponse.ok(
service.getAll(
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
)
@PostMapping
fun create(
@RequestBody request: CreatePointRewardPolicyRequest
) = ApiResponse.ok(service.create(request))
@PutMapping("/{id}")
fun update(
@PathVariable id: Long,
@RequestBody request: ModifyPointRewardPolicyRequest
) = ApiResponse.ok(service.update(id, request))
}

View File

@ -0,0 +1,57 @@
package kr.co.vividnext.sodalive.admin.point
import com.querydsl.core.types.dsl.DateTimePath
import com.querydsl.core.types.dsl.Expressions
import com.querydsl.core.types.dsl.StringTemplate
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.point.PointRewardPolicy
import kr.co.vividnext.sodalive.point.QPointRewardPolicy.pointRewardPolicy
import org.springframework.data.jpa.repository.JpaRepository
import java.time.LocalDateTime
interface PointPolicyRepository : JpaRepository<PointRewardPolicy, Long>, PointPolicyQueryRepository
interface PointPolicyQueryRepository {
fun getTotalCount(): Int
fun getAll(offset: Long, limit: Long): List<GetPointRewardPolicyListItem>
}
class PointPolicyQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory
) : PointPolicyQueryRepository {
override fun getTotalCount(): Int {
return queryFactory
.select(pointRewardPolicy.id)
.from(pointRewardPolicy)
.fetch()
.size
}
override fun getAll(offset: Long, limit: Long): List<GetPointRewardPolicyListItem> {
return queryFactory
.select(
QGetPointRewardPolicyListItem(
pointRewardPolicy.id,
pointRewardPolicy.title,
pointRewardPolicy.actionType,
pointRewardPolicy.threshold,
pointRewardPolicy.pointAmount,
getFormattedDate(pointRewardPolicy.startDate),
getFormattedDate(pointRewardPolicy.endDate),
pointRewardPolicy.isActive
)
)
.from(pointRewardPolicy)
.orderBy(pointRewardPolicy.isActive.desc(), pointRewardPolicy.startDate.desc())
.offset(offset)
.limit(limit)
.fetch()
}
private fun getFormattedDate(dateTimePath: DateTimePath<LocalDateTime>): StringTemplate {
return Expressions.stringTemplate(
"COALESCE(DATE_FORMAT(CONVERT_TZ({0}, 'UTC', 'Asia/Seoul'), '%Y-%m-%d %H:%i'), '')",
dateTimePath
)
}
}

View File

@ -0,0 +1,55 @@
package kr.co.vividnext.sodalive.admin.point
import kr.co.vividnext.sodalive.common.SodaException
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Service
class PointPolicyService(private val repository: PointPolicyRepository) {
fun getAll(offset: Long, limit: Long): GetPointRewardPolicyListResponse {
val totalCount = repository.getTotalCount()
val items = repository.getAll(offset, limit)
return GetPointRewardPolicyListResponse(totalCount, items)
}
@Transactional
fun create(request: CreatePointRewardPolicyRequest) {
val pointPolicy = request.toEntity()
repository.save(pointPolicy)
}
@Transactional
fun update(id: Long, request: ModifyPointRewardPolicyRequest) {
val pointPolicy = repository.findByIdOrNull(id)
?: throw SodaException("잘못된 접근입니다.")
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
if (request.title != null) {
pointPolicy.title = request.title
}
if (request.startDate != null) {
pointPolicy.startDate = LocalDateTime.parse(request.startDate, dateTimeFormatter)
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
}
if (request.endDate != null) {
pointPolicy.endDate = LocalDateTime.parse(request.endDate, dateTimeFormatter).withSecond(59)
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
}
if (request.isActive != null) {
pointPolicy.isActive = request.isActive
}
}
}

View File

@ -49,10 +49,12 @@ class AuditionApplicantQueryRepositoryImpl(
return queryFactory
.select(auditionApplicant.id)
.from(auditionApplicant)
.innerJoin(auditionApplicant.member, member)
.innerJoin(auditionApplicant.role, auditionRole)
.where(
auditionRole.id.eq(auditionRoleId),
auditionApplicant.isActive.isTrue
auditionApplicant.isActive.isTrue,
member.isActive.isTrue
)
.fetch()
.size
@ -87,7 +89,8 @@ class AuditionApplicantQueryRepositoryImpl(
.leftJoin(auditionVote).on(auditionApplicant.id.eq(auditionVote.applicant.id))
.where(
auditionRole.id.eq(auditionRoleId),
auditionApplicant.isActive.isTrue
auditionApplicant.isActive.isTrue,
member.isActive.isTrue
)
.groupBy(auditionApplicant.id)
.orderBy(orderBy)

View File

@ -42,6 +42,7 @@ data class AudioContent(
val isGeneratePreview: Boolean = true,
var isOnlyRental: Boolean = false,
var isAdult: Boolean = false,
var isPointAvailable: Boolean = false,
var isCommentAvailable: Boolean = true,
var isFullDetailVisible: Boolean = true
) : BaseEntity() {

View File

@ -214,6 +214,7 @@ class AudioContentService(
purchaseOption = purchaseOption,
isGeneratePreview = request.isGeneratePreview,
isOnlyRental = isOnlyRental,
isPointAvailable = request.isPointAvailable,
isCommentAvailable = request.isCommentAvailable,
isFullDetailVisible = isFullDetailVisible
)
@ -707,7 +708,8 @@ class AudioContentService(
),
previousContent = previousContent,
nextContent = nextContent,
buyerList = buyerList
buyerList = buyerList,
isAvailableUsePoint = audioContent.isPointAvailable
)
}

View File

@ -13,6 +13,7 @@ data class CreateAudioContentRequest(
val isAdult: Boolean = false,
val isGeneratePreview: Boolean = false,
val isOnlyRental: Boolean = false,
val isPointAvailable: Boolean = false,
val isCommentAvailable: Boolean = false,
val isFullDetailVisible: Boolean = true,
val previewStartTime: String? = null,

View File

@ -38,7 +38,8 @@ data class GetAudioContentDetailResponse(
val creator: AudioContentCreator,
val previousContent: OtherContentResponse?,
val nextContent: OtherContentResponse?,
val buyerList: List<ContentBuyer>
val buyerList: List<ContentBuyer>,
val isAvailableUsePoint: Boolean
)
data class OtherContentResponse @QueryProjection constructor(

View File

@ -25,6 +25,7 @@ data class Order(
var isActive: Boolean = true
) : BaseEntity() {
var can: Int = 0
var point: Int = 0
val startDate: LocalDateTime = LocalDateTime.now()
var endDate: LocalDateTime? = null

View File

@ -10,6 +10,7 @@ import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository
import kr.co.vividnext.sodalive.content.like.AudioContentLikeRepository
import kr.co.vividnext.sodalive.content.main.GetAudioContentMainItem
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.point.PointUsageService
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@ -19,6 +20,7 @@ import java.time.LocalDateTime
@Transactional(readOnly = true)
class OrderService(
private val repository: OrderRepository,
private val pointUsageService: PointUsageService,
private val canPaymentService: CanPaymentService,
private val audioContentRepository: AudioContentRepository,
private val audioContentCommentQueryRepository: AudioContentCommentRepository,
@ -41,13 +43,27 @@ class OrderService(
orderContent(orderType, content, member)
}
canPaymentService.spendCan(
memberId = member.id!!,
needCan = order.can,
canUsage = CanUsage.ORDER_CONTENT,
order = order,
container = container
)
val usedPoint = if (order.type == OrderType.RENTAL && content.isPointAvailable) {
pointUsageService.usePoint(member.id!!, order.can)
} else {
0
}
order.point = usedPoint
val remainingCan = order.can - (usedPoint / 10)
if (order.type == OrderType.RENTAL && content.isPointAvailable && usedPoint > 0) {
order.can = remainingCan
}
if (remainingCan > 0) {
canPaymentService.spendCan(
memberId = member.id!!,
needCan = remainingCan,
canUsage = CanUsage.ORDER_CONTENT,
order = order,
container = container
)
}
}
private fun orderContent(orderType: OrderType, content: AudioContent, member: Member): Order {

View File

@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.creator.admin.calculate
import com.querydsl.core.types.dsl.CaseBuilder
import com.querydsl.core.types.dsl.DateTimePath
import com.querydsl.core.types.dsl.Expressions
import com.querydsl.core.types.dsl.StringTemplate
@ -95,6 +96,10 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
limit: Long
): List<GetCalculateContentQueryData> {
val orderFormattedDate = getFormattedDate(order.createdAt)
val pointGroup = CaseBuilder()
.`when`(order.point.loe(0)).then(0)
.otherwise(1)
return queryFactory
.select(
QGetCalculateContentQueryData(
@ -106,6 +111,7 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
order.can,
order.id.count(),
order.can.sum(),
order.point.sum(),
creatorSettlementRatio.contentSettlementRatio
)
)
@ -125,6 +131,7 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
order.type,
orderFormattedDate,
order.can,
pointGroup,
creatorSettlementRatio.contentSettlementRatio
)
.offset(offset)
@ -167,6 +174,10 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
offset: Long,
limit: Long
): List<GetCumulativeSalesByContentQueryData> {
val pointGroup = CaseBuilder()
.`when`(order.point.loe(0)).then(0)
.otherwise(1)
return queryFactory
.select(
QGetCumulativeSalesByContentQueryData(
@ -177,6 +188,7 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
order.can,
order.id.count(),
order.can.sum(),
order.point.sum(),
creatorSettlementRatio.contentSettlementRatio
)
)
@ -189,7 +201,14 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
audioContent.member.id.eq(memberId)
.and(order.isActive.isTrue)
)
.groupBy(member.id, audioContent.id, order.type, order.can)
.groupBy(
member.id,
audioContent.id,
order.type,
order.can,
pointGroup,
creatorSettlementRatio.contentSettlementRatio
)
.offset(offset)
.limit(limit)
.orderBy(member.id.desc(), audioContent.id.desc())

View File

@ -84,6 +84,7 @@ class CreatorAdminAudioContentQueryRepositoryImpl(
audioContent.limited,
audioContent.remaining,
audioContent.isAdult,
audioContent.isPointAvailable,
audioContent.isCommentAvailable,
audioContent.duration,
audioContent.content,

View File

@ -131,6 +131,10 @@ class CreatorAdminContentService(
audioContent.isAdult = request.isAdult
}
if (request.isPointAvailable != null) {
audioContent.isPointAvailable = request.isPointAvailable
}
if (request.isCommentAvailable != null) {
audioContent.isCommentAvailable = request.isCommentAvailable
}

View File

@ -18,6 +18,7 @@ data class GetCreatorAdminContentListItem @QueryProjection constructor(
val totalContentCount: Int?,
val remainingContentCount: Int?,
val isAdult: Boolean,
val isPointAvailable: Boolean,
val isCommentAvailable: Boolean,
val remainingTime: String,
var contentUrl: String,

View File

@ -7,5 +7,6 @@ data class UpdateCreatorAdminContentRequest(
val price: Int?,
val isAdult: Boolean?,
val isActive: Boolean?,
val isPointAvailable: Boolean?,
val isCommentAvailable: Boolean?
)

View File

@ -4,6 +4,9 @@ import com.google.firebase.messaging.AndroidConfig
import com.google.firebase.messaging.ApnsConfig
import com.google.firebase.messaging.Aps
import com.google.firebase.messaging.FirebaseMessaging
import com.google.firebase.messaging.FirebaseMessagingException
import com.google.firebase.messaging.Message
import com.google.firebase.messaging.MessagingErrorCode
import com.google.firebase.messaging.MulticastMessage
import com.google.firebase.messaging.Notification
import org.slf4j.LoggerFactory
@ -26,8 +29,14 @@ class FcmService {
creatorId: Long? = null,
auditionId: Long? = null
) {
if (tokens.isNotEmpty()) {
logger.info("os: $container")
if (tokens.isEmpty()) return
logger.info("os: $container")
var targets = tokens
val maxAttempts = 3
var attempt = 1
while (attempt <= maxAttempts && targets.isNotEmpty()) {
val multicastMessage = MulticastMessage.builder()
.addAllTokens(tokens)
@ -82,8 +91,80 @@ class FcmService {
}
val response = FirebaseMessaging.getInstance().sendEachForMulticast(multicastMessage.build())
logger.info("보내기 성공: ${response.successCount}")
logger.info("보내기 실패: ${response.failureCount}")
val failedTokens = mutableListOf<String>()
response.responses.forEachIndexed { index, res ->
if (!res.isSuccessful) {
val exception = res.exception
val token = targets[index]
if (exception?.messagingErrorCode == MessagingErrorCode.UNREGISTERED) {
logger.error("[FCM] ❌ UNREGISTERED → $token")
// 필요 시 DB에서 삭제
} else {
logger.error("[FCM] ❌ 실패: $token / ${exception?.messagingErrorCode}")
failedTokens.add(token)
}
}
}
if (failedTokens.isEmpty()) {
logger.info("[FCM] ✅ 전체 전송 성공")
return
}
targets = failedTokens
attempt++
}
if (targets.isNotEmpty()) {
logger.error("[FCM] ❌ 최종 실패 대상 ${targets.size}명 → $targets")
}
}
fun sendPointGranted(token: String, point: Int) {
val data = mapOf(
"type" to "POINT_GRANTED",
"point" to point.toString(),
"message" to "${point}포인트가 지급되었습니다!"
)
var attempts = 0
val maxAttempts = 3
while (attempts < maxAttempts) {
try {
val message = Message.builder()
.setToken(token)
.putAllData(data)
.build()
val response = FirebaseMessaging.getInstance().send(message)
logger.info("[FCM] ✅ 성공 (attempt ${attempts + 1}): messageId=$response")
return // 성공 시 즉시 종료
} catch (e: FirebaseMessagingException) {
attempts++
// "registration-token-not-registered" 예외 코드 확인
if (e.messagingErrorCode == MessagingErrorCode.UNREGISTERED) {
logger.error("[FCM] ❌ 실패: 토큰이 등록되지 않음 (등록 해제됨) → 재시도 안함")
return
}
logger.error("[FCM] ❌ 실패 (attempt $attempts): ${e.errorCode} - ${e.message}")
if (attempts >= maxAttempts) {
logger.error("[FCM] ❌ 최종 실패: 전송 불가")
}
} catch (e: Exception) {
// Firebase 이외의 예외도 잡기
attempts++
logger.error("[FCM] ❌ 실패 (attempt $attempts): ${e.message}")
if (attempts >= maxAttempts) {
logger.error("[FCM] ❌ 최종 실패: 알 수 없는 오류")
}
}
}
}
}

View File

@ -13,6 +13,8 @@ import kr.co.vividnext.sodalive.member.notification.UpdateNotificationSettingReq
import kr.co.vividnext.sodalive.member.signUp.SignUpRequestV2
import kr.co.vividnext.sodalive.member.social.google.GoogleAuthService
import kr.co.vividnext.sodalive.member.social.kakao.KakaoAuthService
import kr.co.vividnext.sodalive.useraction.ActionType
import kr.co.vividnext.sodalive.useraction.UserActionService
import org.springframework.data.domain.Pageable
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.core.userdetails.User
@ -34,7 +36,8 @@ class MemberController(
private val service: MemberService,
private val kakaoAuthService: KakaoAuthService,
private val googleAuthService: GoogleAuthService,
private val trackingService: AdTrackingService
private val trackingService: AdTrackingService,
private val userActionService: UserActionService
) {
@GetMapping("/check/email")
fun checkEmail(@RequestParam email: String) = service.duplicateCheckEmail(email)
@ -60,6 +63,12 @@ class MemberController(
)
}
userActionService.recordAction(
memberId = response.memberId,
actionType = ActionType.SIGN_UP,
pushToken = request.pushToken
)
return ApiResponse.ok(message = "회원가입을 축하드립니다.", data = response.loginResponse)
}
@ -332,7 +341,7 @@ class MemberController(
}
val token = authHeader.substring(7)
val response = googleAuthService.authenticate(token, request.container, request.marketingPid)
val response = googleAuthService.authenticate(token, request.container, request.marketingPid, request.pushToken)
if (!response.marketingPid.isNullOrBlank()) {
trackingService.saveTrackingHistory(
@ -342,6 +351,12 @@ class MemberController(
)
}
userActionService.recordAction(
memberId = response.memberId,
actionType = ActionType.SIGN_UP,
pushToken = request.pushToken
)
return ApiResponse.ok(message = "회원가입을 축하드립니다.", data = response.loginResponse)
}
@ -355,7 +370,7 @@ class MemberController(
}
val token = authHeader.substring(7)
val response = kakaoAuthService.authenticate(token, request.container, request.marketingPid)
val response = kakaoAuthService.authenticate(token, request.container, request.marketingPid, request.pushToken)
if (!response.marketingPid.isNullOrBlank()) {
trackingService.saveTrackingHistory(
@ -365,6 +380,12 @@ class MemberController(
)
}
userActionService.recordAction(
memberId = response.memberId,
actionType = ActionType.SIGN_UP,
pushToken = request.pushToken
)
return ApiResponse.ok(message = "회원가입을 축하드립니다.", data = response.loginResponse)
}
}

View File

@ -42,6 +42,7 @@ import kr.co.vividnext.sodalive.member.stipulation.StipulationRepository
import kr.co.vividnext.sodalive.member.tag.MemberCreatorTag
import kr.co.vividnext.sodalive.member.tag.MemberTagRepository
import kr.co.vividnext.sodalive.member.token.MemberTokenRepository
import kr.co.vividnext.sodalive.point.MemberPointRepository
import kr.co.vividnext.sodalive.utils.generateFileName
import kr.co.vividnext.sodalive.utils.generatePassword
import org.springframework.beans.factory.annotation.Value
@ -77,6 +78,7 @@ class MemberService(
private val memberTagRepository: MemberTagRepository,
private val liveReservationRepository: LiveReservationRepository,
private val chargeRepository: ChargeRepository,
private val memberPointRepository: MemberPointRepository,
private val orderService: OrderService,
private val emailService: SendEmailService,
@ -125,6 +127,7 @@ class MemberService(
gender = Gender.NONE,
container = request.container
)
member.pushToken = request.pushToken
if (!request.marketingPid.isNullOrBlank()) {
member.activePid = request.marketingPid
@ -261,6 +264,11 @@ class MemberService(
limit = 4
)
val totalPoint = memberPointRepository.findByMemberIdAndExpiresAtAfterOrderByExpiresAtAsc(
memberId = member.id!!,
expiresAt = LocalDateTime.now()
).sumOf { it.point }
return MyPageResponse(
nickname = member.nickname,
profileUrl = if (member.profileImage != null) {
@ -270,6 +278,7 @@ class MemberService(
},
chargeCan = member.getChargeCan(container = container),
rewardCan = member.getRewardCan(container = container),
point = totalPoint,
youtubeUrl = member.youtubeUrl,
instagramUrl = member.instagramUrl,
websiteUrl = member.websiteUrl,
@ -780,7 +789,12 @@ class MemberService(
}
@Transactional
fun findOrRegister(googleUserInfo: GoogleUserInfo, container: String, marketingPid: String?): Member {
fun findOrRegister(
googleUserInfo: GoogleUserInfo,
container: String,
marketingPid: String?,
pushToken: String?
): Member {
val findMember = repository.findByGoogleId(googleUserInfo.sub)
if (findMember != null) {
if (findMember.isActive) {
@ -810,6 +824,7 @@ class MemberService(
provider = MemberProvider.GOOGLE,
container = container
)
member.pushToken = pushToken
if (!marketingPid.isNullOrBlank()) {
member.activePid = marketingPid
@ -823,7 +838,12 @@ class MemberService(
}
@Transactional
fun findOrRegister(kakaoUserInfo: KakaoUserInfo, container: String, marketingPid: String?): Member {
fun findOrRegister(
kakaoUserInfo: KakaoUserInfo,
container: String,
marketingPid: String?,
pushToken: String?
): Member {
val findMember = repository.findByKakaoId(kakaoUserInfo.id)
if (findMember != null) {
if (findMember.isActive) {
@ -853,6 +873,7 @@ class MemberService(
provider = MemberProvider.KAKAO,
container = container
)
member.pushToken = pushToken
if (!marketingPid.isNullOrBlank()) {
member.activePid = marketingPid

View File

@ -3,6 +3,8 @@ package kr.co.vividnext.sodalive.member.auth
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.useraction.ActionType
import kr.co.vividnext.sodalive.useraction.UserActionService
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
@ -11,7 +13,10 @@ import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/auth")
class AuthController(private val service: AuthService) {
class AuthController(
private val service: AuthService,
private val userActionService: UserActionService
) {
@PostMapping
fun authVerify(
@RequestBody request: AuthVerifyRequest,
@ -26,6 +31,12 @@ class AuthController(private val service: AuthService) {
throw SodaException("운영정책을 위반하여 이용을 제한합니다.")
}
userActionService.recordAction(
memberId = member.id!!,
actionType = ActionType.USER_AUTHENTICATION,
pushToken = member.pushToken
)
ApiResponse.ok(service.authenticate(authenticateData, member.id!!))
}
}

View File

@ -7,4 +7,8 @@ data class LoginRequest(
val isCreator: Boolean = false
)
data class SocialLoginRequest(val container: String, val marketingPid: String? = null)
data class SocialLoginRequest(
val container: String,
val pushToken: String? = null,
val marketingPid: String? = null
)

View File

@ -7,6 +7,7 @@ data class MyPageResponse(
val profileUrl: String,
val chargeCan: Int,
val rewardCan: Int,
val point: Int,
val youtubeUrl: String?,
val instagramUrl: String?,
val websiteUrl: String? = null,

View File

@ -16,6 +16,7 @@ data class SignUpRequest(
data class SignUpRequestV2(
val email: String,
val password: String,
val pushToken: String? = null,
val marketingPid: String? = null,
val isAgreeTermsOfService: Boolean,
val isAgreePrivacyPolicy: Boolean,

View File

@ -19,10 +19,15 @@ class GoogleAuthService(
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) {
fun authenticate(idToken: String, container: String, marketingPid: String?): SocialLoginResponse {
fun authenticate(
idToken: String,
container: String,
marketingPid: String?,
pushToken: String?
): SocialLoginResponse {
val googleUserInfo = googleService.getUserInfo(idToken)
?: throw SodaException("구글 로그인을 하지 못했습니다. 다시 시도해 주세요")
val member = memberService.findOrRegister(googleUserInfo, container, marketingPid)
val member = memberService.findOrRegister(googleUserInfo, container, marketingPid, pushToken)
val principal = MemberAdapter(member)
val authToken = GoogleAuthenticationToken(idToken, principal.authorities)
authToken.setPrincipal(principal)

View File

@ -19,10 +19,15 @@ class KakaoAuthService(
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) {
fun authenticate(accessToken: String, container: String, marketingPid: String?): SocialLoginResponse {
fun authenticate(
accessToken: String,
container: String,
marketingPid: String?,
pushToken: String?
): SocialLoginResponse {
val kakaoUserInfo = kakaoService.getUserInfo(accessToken)
?: throw SodaException("카카오 로그인을 하지 못했습니다. 다시 시도해 주세요")
val member = memberService.findOrRegister(kakaoUserInfo, container, marketingPid)
val member = memberService.findOrRegister(kakaoUserInfo, container, marketingPid, pushToken)
val principal = MemberAdapter(member)
val authToken = KakaoAuthenticationToken(accessToken, principal.authorities)
authToken.setPrincipal(principal)

View File

@ -0,0 +1,17 @@
package kr.co.vividnext.sodalive.point
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.useraction.ActionType
import java.time.LocalDateTime
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
@Entity
data class MemberPoint(
val memberId: Long,
var point: Int,
@Enumerated(EnumType.STRING)
val actionType: ActionType,
val expiresAt: LocalDateTime
) : BaseEntity()

View File

@ -0,0 +1,29 @@
package kr.co.vividnext.sodalive.point
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.point.QMemberPoint.memberPoint
import org.springframework.data.jpa.repository.JpaRepository
import java.time.LocalDateTime
interface MemberPointRepository : JpaRepository<MemberPoint, Long>, MemberPointQueryRepository
interface MemberPointQueryRepository {
fun findByMemberIdAndExpiresAtAfterOrderByExpiresAtAsc(memberId: Long, expiresAt: LocalDateTime): List<MemberPoint>
}
class MemberPointQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory
) : MemberPointQueryRepository {
override fun findByMemberIdAndExpiresAtAfterOrderByExpiresAtAsc(
memberId: Long,
expiresAt: LocalDateTime
): List<MemberPoint> {
return queryFactory
.selectFrom(memberPoint)
.where(
memberPoint.memberId.eq(memberId),
memberPoint.expiresAt.goe(expiresAt)
)
.fetch()
}
}

View File

@ -0,0 +1,16 @@
package kr.co.vividnext.sodalive.point
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.useraction.ActionType
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
@Entity
data class PointGrantLog(
val memberId: Long,
val point: Int,
@Enumerated(EnumType.STRING)
val actionType: ActionType,
val policyId: Long?
) : BaseEntity()

View File

@ -0,0 +1,27 @@
package kr.co.vividnext.sodalive.point
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.point.QPointGrantLog.pointGrantLog
import org.springframework.data.jpa.repository.JpaRepository
interface PointGrantLogRepository : JpaRepository<PointGrantLog, Long>, PointGrantLogQueryRepository
interface PointGrantLogQueryRepository {
fun existsByMemberIdAndPolicyId(memberId: Long, policyId: Long): Boolean
}
class PointGrantLogQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory
) : PointGrantLogQueryRepository {
override fun existsByMemberIdAndPolicyId(memberId: Long, policyId: Long): Boolean {
return queryFactory
.select(pointGrantLog.id)
.from(pointGrantLog)
.where(
pointGrantLog.memberId.eq(memberId),
pointGrantLog.policyId.eq(policyId)
)
.fetch()
.isNotEmpty()
}
}

View File

@ -0,0 +1,20 @@
package kr.co.vividnext.sodalive.point
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.useraction.ActionType
import java.time.LocalDateTime
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
@Entity
data class PointRewardPolicy(
var title: String,
@Enumerated(EnumType.STRING)
val actionType: ActionType,
val threshold: Int,
val pointAmount: Int,
var startDate: LocalDateTime,
var endDate: LocalDateTime? = null,
var isActive: Boolean = true
) : BaseEntity()

View File

@ -0,0 +1,36 @@
package kr.co.vividnext.sodalive.point
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.point.QPointRewardPolicy.pointRewardPolicy
import kr.co.vividnext.sodalive.useraction.ActionType
import org.springframework.data.jpa.repository.JpaRepository
import java.time.LocalDateTime
interface PointRewardPolicyRepository : JpaRepository<PointRewardPolicy, Long>, PointRewardPolicyQueryRepository
interface PointRewardPolicyQueryRepository {
fun findByActionTypeAndIsActiveTrue(actionType: ActionType, nowDateTime: LocalDateTime): PointRewardPolicy?
}
class PointRewardPolicyQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory
) : PointRewardPolicyQueryRepository {
override fun findByActionTypeAndIsActiveTrue(
actionType: ActionType,
nowDateTime: LocalDateTime
): PointRewardPolicy? {
return queryFactory
.selectFrom(pointRewardPolicy)
.where(
pointRewardPolicy.isActive.isTrue
.and(pointRewardPolicy.actionType.eq(actionType))
.and(pointRewardPolicy.startDate.loe(nowDateTime))
.and(
pointRewardPolicy.endDate.goe(nowDateTime)
.or(pointRewardPolicy.endDate.isNull)
)
)
.orderBy(pointRewardPolicy.endDate.asc())
.fetchFirst()
}
}

View File

@ -0,0 +1,43 @@
package kr.co.vividnext.sodalive.point
import org.springframework.stereotype.Service
import java.time.LocalDateTime
@Service
class PointUsageService(
private val memberPointRepository: MemberPointRepository,
private val usePointRepository: UsePointRepository
) {
fun usePoint(memberId: Long, contentPrice: Int): Int {
val now = LocalDateTime.now()
val maxUsablePoint = contentPrice * 10
val points = memberPointRepository.findByMemberIdAndExpiresAtAfterOrderByExpiresAtAsc(
memberId = memberId,
expiresAt = now
)
val totalAvailable = points.sumOf { it.point }
val usablePoint = minOf(totalAvailable, maxUsablePoint).floorToNearest10()
var remaining = usablePoint
var used = 0
for (p in points) {
if (remaining <= 0) break
val usable = minOf(p.point, remaining)
p.point -= usable
remaining -= usable
used += usable
}
if (used > 0) {
memberPointRepository.saveAll(points)
usePointRepository.save(UsePoint(memberId = memberId, amount = used))
}
return used
}
private fun Int.floorToNearest10(): Int = (this / 10) * 10
}

View File

@ -0,0 +1,10 @@
package kr.co.vividnext.sodalive.point
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.Entity
@Entity
data class UsePoint(
val memberId: Long,
val amount: Int
) : BaseEntity()

View File

@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.point
import org.springframework.data.jpa.repository.JpaRepository
interface UsePointRepository : JpaRepository<UsePoint, Long>

View File

@ -0,0 +1,6 @@
package kr.co.vividnext.sodalive.useraction
enum class ActionType(val displayName: String) {
SIGN_UP("회원가입"),
USER_AUTHENTICATION("본인인증")
}

View File

@ -0,0 +1,13 @@
package kr.co.vividnext.sodalive.useraction
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
@Entity
data class UserActionLog(
val memberId: Long,
@Enumerated(EnumType.STRING)
val actionType: ActionType
) : BaseEntity()

View File

@ -0,0 +1,39 @@
package kr.co.vividnext.sodalive.useraction
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.useraction.QUserActionLog.userActionLog
import org.springframework.data.jpa.repository.JpaRepository
import java.time.LocalDateTime
interface UserActionLogRepository : JpaRepository<UserActionLog, Long>, UserActionLogQueryRepository
interface UserActionLogQueryRepository {
fun countByMemberIdAndActionTypeAndCreatedAtBetween(
memberId: Long,
actionType: ActionType,
startDate: LocalDateTime,
endDate: LocalDateTime
): Int
}
class UserActionLogQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory
) : UserActionLogQueryRepository {
override fun countByMemberIdAndActionTypeAndCreatedAtBetween(
memberId: Long,
actionType: ActionType,
startDate: LocalDateTime,
endDate: LocalDateTime
): Int {
return queryFactory
.select(userActionLog.id)
.from(userActionLog)
.where(
userActionLog.memberId.eq(memberId)
.and(userActionLog.actionType.eq(actionType))
.and(userActionLog.createdAt.between(startDate, endDate))
)
.fetch()
.size
}
}

View File

@ -0,0 +1,69 @@
package kr.co.vividnext.sodalive.useraction
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kr.co.vividnext.sodalive.fcm.FcmService
import kr.co.vividnext.sodalive.point.MemberPoint
import kr.co.vividnext.sodalive.point.MemberPointRepository
import kr.co.vividnext.sodalive.point.PointGrantLog
import kr.co.vividnext.sodalive.point.PointGrantLogRepository
import kr.co.vividnext.sodalive.point.PointRewardPolicyRepository
import org.springframework.stereotype.Service
import java.time.LocalDateTime
@Service
class UserActionService(
private val repository: UserActionLogRepository,
private val policyRepository: PointRewardPolicyRepository,
private val grantLogRepository: PointGrantLogRepository,
private val memberPointRepository: MemberPointRepository,
private val fcmService: FcmService
) {
private val coroutineScope = CoroutineScope(Dispatchers.IO)
fun recordAction(memberId: Long, actionType: ActionType, pushToken: String?) {
coroutineScope.launch {
val now = LocalDateTime.now()
repository.save(UserActionLog(memberId, actionType))
val policy = policyRepository.findByActionTypeAndIsActiveTrue(actionType, now)
if (policy != null) {
val actionCount = repository.countByMemberIdAndActionTypeAndCreatedAtBetween(
memberId = memberId,
actionType = actionType,
startDate = policy.startDate,
endDate = policy.endDate ?: now
)
if (actionCount < policy.threshold) return@launch
val alreadyGranted = grantLogRepository.existsByMemberIdAndPolicyId(memberId, policy.id!!)
if (alreadyGranted) return@launch
memberPointRepository.save(
MemberPoint(
memberId = memberId,
point = policy.pointAmount,
actionType = actionType,
expiresAt = now.plusDays(3)
)
)
grantLogRepository.save(
PointGrantLog(
memberId = memberId,
point = policy.pointAmount,
actionType = actionType,
policyId = policy.id!!
)
)
if (pushToken != null) {
fcmService.sendPointGranted(pushToken, policy.pointAmount)
}
}
}
}
}