Merge pull request 'test' (#1) from test into main

Reviewed-on: #1
This commit is contained in:
klaus 2023-08-16 02:30:36 +00:00
commit d55514e3a7
349 changed files with 16381 additions and 36 deletions

1
.gitignore vendored
View File

@ -1,5 +1,6 @@
HELP.md
.gradle
.envrc
build/
!**/src/main/**/build/
!**/src/test/**/build/

18
appspec.yml Normal file
View File

@ -0,0 +1,18 @@
version: 0.0
os: linux
files:
- source: /
destination: /home/ec2-user
overwrite: yes
hooks:
ApplicationStart:
- location: scripts/run_process.sh # ApplicationStart 단계에서 해당 파일을 실행해라
timeout: 60
runas: ec2-user
ApplicationStop:
- location: scripts/kill_process.sh # ApplicationStart 단계에서 해당 파일을 실행해라
timeout: 100
runas: ec2-user

View File

@ -1,8 +1,8 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
id("org.springframework.boot") version "2.7.14"
id("io.spring.dependency-management") version "1.0.15.RELEASE"
id("org.springframework.boot") version "2.7.14"
id("io.spring.dependency-management") version "1.0.15.RELEASE"
val kotlinVersion = "1.6.21"
kotlin("jvm") version kotlinVersion
@ -18,42 +18,68 @@ version = "0.0.1-SNAPSHOT"
val querydslVersion = "5.0.0"
java {
sourceCompatibility = JavaVersion.VERSION_11
sourceCompatibility = JavaVersion.VERSION_11
}
repositories {
mavenCentral()
mavenCentral()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
// jwt
implementation("io.jsonwebtoken:jjwt-api:0.11.5")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5")
// querydsl (추가 설정)
implementation("com.querydsl:querydsl-jpa:$querydslVersion")
kapt("com.querydsl:querydsl-apt:$querydslVersion:jpa")
kapt("org.springframework.boot:spring-boot-configuration-processor")
developmentOnly("org.springframework.boot:spring-boot-devtools")
runtimeOnly("com.h2database:h2")
runtimeOnly("com.mysql:mysql-connector-j")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
// aws
implementation("com.amazonaws:aws-java-sdk-ses:1.12.380")
implementation("com.amazonaws:aws-java-sdk-s3:1.12.380")
implementation("com.amazonaws:aws-java-sdk-cloudfront:1.12.380")
// bootpay
implementation("io.github.bootpay:backend:2.2.1")
implementation("com.squareup.okhttp3:okhttp:4.9.3")
implementation("org.json:json:20230227")
implementation("com.google.code.findbugs:jsr305:3.0.2")
// firebase admin sdk
implementation("com.google.firebase:firebase-admin:9.2.0")
developmentOnly("org.springframework.boot:spring-boot-devtools")
runtimeOnly("com.h2database:h2")
runtimeOnly("com.mysql:mysql-connector-j")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
}
allOpen {
annotation("javax.persistence.Entity")
annotation("javax.persistence.MappedSuperclass")
annotation("javax.persistence.Embeddable")
}
tasks.withType<KotlinCompile> {
kotlinOptions {
freeCompilerArgs += "-Xjsr305=strict"
jvmTarget = "11"
}
kotlinOptions {
freeCompilerArgs += "-Xjsr305=strict"
jvmTarget = "11"
}
}
tasks.withType<Test> {
useJUnitPlatform()
useJUnitPlatform()
}
tasks.getByName<Jar>("jar") {

View File

@ -4,3 +4,5 @@ distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
org.gradle.daemon=true
org.gradle.configureondemand=true

16
scripts/kill_process.sh Normal file
View File

@ -0,0 +1,16 @@
#!/bin/bash
BUILD_JAR=$(ls /home/ec2-user/build/libs/*.jar) # jar가 위치하는 곳
JAR_NAME=$(basename $BUILD_JAR)
echo "> build 파일명: $JAR_NAME" >> /home/ec2-user/deploy.log
echo "> 현재 실행중인 애플리케이션 pid 확인" >> /home/ec2-user/deploy.log
CURRENT_PID=$(pgrep -f $JAR_NAME)
if [ -z $CURRENT_PID ]
then
echo "> 현재 구동중인 애플리케이션이 없으므로 종료하지 않습니다." >> /home/ec2-user/deploy.log
else
echo "> kill -15 $CURRENT_PID"
kill -15 $CURRENT_PID
sleep 5
fi

12
scripts/run_process.sh Normal file
View File

@ -0,0 +1,12 @@
#!/bin/bash
BUILD_JAR=$(ls /home/ec2-user/build/libs/*.jar) # jar가 위치하는 곳
JAR_NAME=$(basename $BUILD_JAR)
echo "> build 파일 복사" >> /home/ec2-user/deploy.log
DEPLOY_PATH=/home/ec2-user/
cp $BUILD_JAR $DEPLOY_PATH
DEPLOY_JAR=$DEPLOY_PATH$JAR_NAME
echo "> DEPLOY_JAR 배포" >> /home/ec2-user/deploy.log
chmod +x $DEPLOY_JAR
nohup java -jar $DEPLOY_JAR >> /home/ec2-user/deploy.log 2> /dev/null < /dev/null &

View File

@ -2,10 +2,12 @@ package kr.co.vividnext.sodalive
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.scheduling.annotation.EnableAsync
@SpringBootApplication
class SodaliveApplication
@EnableAsync
class SodaLiveApplication
fun main(args: Array<String>) {
runApplication<SodaliveApplication>(*args)
runApplication<SodaLiveApplication>(*args)
}

View File

@ -0,0 +1,7 @@
package kr.co.vividnext.sodalive.admin.can
data class AdminCanChargeRequest(
val memberId: Long,
val method: String,
val can: Int
)

View File

@ -0,0 +1,24 @@
package kr.co.vividnext.sodalive.admin.can
import kr.co.vividnext.sodalive.common.ApiResponse
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.DeleteMapping
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.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/admin/can")
@PreAuthorize("hasRole('ADMIN')")
class AdminCanController(private val service: AdminCanService) {
@PostMapping
fun insertCan(@RequestBody request: AdminCanRequest) = ApiResponse.ok(service.saveCan(request))
@DeleteMapping("/{id}")
fun deleteCan(@PathVariable id: Long) = ApiResponse.ok(service.deleteCan(id))
@PostMapping("/charge")
fun charge(@RequestBody request: AdminCanChargeRequest) = ApiResponse.ok(service.charge(request))
}

View File

@ -0,0 +1,6 @@
package kr.co.vividnext.sodalive.admin.can
import kr.co.vividnext.sodalive.can.Can
import org.springframework.data.jpa.repository.JpaRepository
interface AdminCanRepository : JpaRepository<Can, Long>

View File

@ -0,0 +1,26 @@
package kr.co.vividnext.sodalive.admin.can
import kr.co.vividnext.sodalive.can.Can
import kr.co.vividnext.sodalive.can.CanStatus
import kr.co.vividnext.sodalive.extensions.moneyFormat
data class AdminCanRequest(
val can: Int,
val rewardCan: Int,
val price: Int
) {
fun toEntity(): Can {
var title = "${can.moneyFormat()}"
if (rewardCan > 0) {
title = "$title + ${rewardCan.moneyFormat()}"
}
return Can(
title = title,
can = can,
rewardCan = rewardCan,
price = price,
status = CanStatus.SALE
)
}
}

View File

@ -0,0 +1,56 @@
package kr.co.vividnext.sodalive.admin.can
import kr.co.vividnext.sodalive.admin.member.AdminMemberRepository
import kr.co.vividnext.sodalive.can.CanStatus
import kr.co.vividnext.sodalive.can.charge.Charge
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
import kr.co.vividnext.sodalive.can.payment.Payment
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.extensions.moneyFormat
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class AdminCanService(
private val repository: AdminCanRepository,
private val chargeRepository: ChargeRepository,
private val memberRepository: AdminMemberRepository
) {
@Transactional
fun saveCan(request: AdminCanRequest) {
repository.save(request.toEntity())
}
@Transactional
fun deleteCan(id: Long) {
val can = repository.findByIdOrNull(id)
?: throw SodaException("잘못된 요청입니다.")
can.status = CanStatus.END_OF_SALE
}
@Transactional
fun charge(request: AdminCanChargeRequest) {
val member = memberRepository.findByIdOrNull(request.memberId)
?: throw SodaException("잘못된 회원번호 입니다.")
if (request.can <= 0) throw SodaException("1 캔 이상 입력하세요.")
if (request.method.isBlank()) throw SodaException("기록내용을 입력하세요.")
val charge = Charge(0, request.can, status = ChargeStatus.ADMIN)
charge.title = "${request.can.moneyFormat()}"
charge.member = member
val payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG)
payment.method = request.method
charge.payment = payment
chargeRepository.save(charge)
member.pgRewardCan += charge.rewardCan
}
}

View File

@ -0,0 +1,26 @@
package kr.co.vividnext.sodalive.admin.charge
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import kr.co.vividnext.sodalive.common.ApiResponse
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@PreAuthorize("hasRole('ADMIN')")
@RequestMapping("/admin/charge/status")
class AdminChargeStatusController(private val service: AdminChargeStatusService) {
@GetMapping
fun getChargeStatus(
@RequestParam startDateStr: String,
@RequestParam endDateStr: String
) = ApiResponse.ok(service.getChargeStatus(startDateStr, endDateStr))
@GetMapping("/detail")
fun getChargeStatusDetail(
@RequestParam startDateStr: String,
@RequestParam paymentGateway: PaymentGateway
) = ApiResponse.ok(service.getChargeStatusDetail(startDateStr, paymentGateway))
}

View File

@ -0,0 +1,90 @@
package kr.co.vividnext.sodalive.admin.charge
import com.querydsl.core.types.dsl.Expressions
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.can.QCan.can1
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
import kr.co.vividnext.sodalive.can.charge.QCharge.charge
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
import kr.co.vividnext.sodalive.can.payment.QPayment.payment
import kr.co.vividnext.sodalive.member.QMember.member
import org.springframework.stereotype.Repository
import java.time.LocalDateTime
@Repository
class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory) {
fun getChargeStatus(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusQueryDto> {
val formattedDate = Expressions.stringTemplate(
"DATE_FORMAT({0}, {1})",
Expressions.dateTimeTemplate(
LocalDateTime::class.java,
"CONVERT_TZ({0},{1},{2})",
charge.createdAt,
"UTC",
"Asia/Seoul"
),
"%Y-%m-%d"
)
return queryFactory
.select(
QGetChargeStatusQueryDto(
formattedDate,
payment.price.sum(),
can1.price.sum(),
payment.id.count(),
payment.paymentGateway
)
)
.from(payment)
.innerJoin(payment.charge, charge)
.leftJoin(charge.can, can1)
.where(
charge.createdAt.goe(startDate)
.and(charge.createdAt.loe(endDate))
.and(charge.status.eq(ChargeStatus.CHARGE))
.and(payment.status.eq(PaymentStatus.COMPLETE))
)
.groupBy(formattedDate, payment.paymentGateway)
.orderBy(formattedDate.desc())
.fetch()
}
fun getChargeStatusDetail(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusDetailQueryDto> {
val formattedDate = Expressions.stringTemplate(
"DATE_FORMAT({0}, {1})",
Expressions.dateTimeTemplate(
LocalDateTime::class.java,
"CONVERT_TZ({0},{1},{2})",
charge.createdAt,
"UTC",
"Asia/Seoul"
),
"%Y-%m-%d %H:%i:%s"
)
return queryFactory
.select(
QGetChargeStatusDetailQueryDto(
member.id,
member.nickname,
payment.method.coalesce(""),
payment.price,
can1.price,
formattedDate
)
)
.from(charge)
.innerJoin(charge.member, member)
.innerJoin(charge.payment, payment)
.leftJoin(charge.can, can1)
.where(
charge.createdAt.goe(startDate)
.and(charge.createdAt.loe(endDate))
.and(charge.status.eq(ChargeStatus.CHARGE))
.and(payment.status.eq(PaymentStatus.COMPLETE))
)
.orderBy(formattedDate.desc())
.fetch()
}
}

View File

@ -0,0 +1,101 @@
package kr.co.vividnext.sodalive.admin.charge
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import org.springframework.stereotype.Service
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Service
class AdminChargeStatusService(val repository: AdminChargeStatusQueryRepository) {
fun getChargeStatus(startDateStr: String, endDateStr: String): List<GetChargeStatusResponse> {
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
val startDate = LocalDate.parse(startDateStr, dateTimeFormatter).atTime(0, 0, 0)
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
val endDate = LocalDate.parse(endDateStr, dateTimeFormatter).atTime(23, 59, 59)
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
var totalChargeAmount = 0
var totalChargeCount = 0L
val chargeStatusList = repository.getChargeStatus(startDate, endDate)
.asSequence()
.map {
val chargeAmount = if (it.paymentGateWay == PaymentGateway.APPLE_IAP) {
it.appleChargeAmount.toInt()
} else {
it.pgChargeAmount
}
val chargeCount = it.chargeCount
totalChargeAmount += chargeAmount
totalChargeCount += chargeCount
GetChargeStatusResponse(
date = it.date,
chargeAmount = chargeAmount,
chargeCount = chargeCount,
pg = it.paymentGateWay.name
)
}
.toMutableList()
chargeStatusList.add(
0,
GetChargeStatusResponse(
date = "합계",
chargeAmount = totalChargeAmount,
chargeCount = totalChargeCount,
pg = ""
)
)
return chargeStatusList.toList()
}
fun getChargeStatusDetail(
startDateStr: String,
paymentGateway: PaymentGateway
): List<GetChargeStatusDetailResponse> {
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
val startDate = LocalDate.parse(startDateStr, dateTimeFormatter).atTime(0, 0, 0)
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
val endDate = LocalDate.parse(startDateStr, dateTimeFormatter).atTime(23, 59, 59)
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
return repository.getChargeStatusDetail(startDate, endDate)
.asSequence()
.filter {
if (paymentGateway == PaymentGateway.APPLE_IAP) {
it.appleChargeAmount > 0
} else {
it.pgChargeAmount > 0
}
}
.map {
GetChargeStatusDetailResponse(
memberId = it.memberId,
nickname = it.nickname,
method = it.method,
amount = if (paymentGateway == PaymentGateway.APPLE_IAP) {
it.appleChargeAmount.toInt()
} else {
it.pgChargeAmount
},
datetime = it.datetime
)
}
.toList()
}
}

View File

@ -0,0 +1,12 @@
package kr.co.vividnext.sodalive.admin.charge
import com.querydsl.core.annotations.QueryProjection
data class GetChargeStatusDetailQueryDto @QueryProjection constructor(
val memberId: Long,
val nickname: String,
val method: String,
val appleChargeAmount: Double,
val pgChargeAmount: Int,
val datetime: String
)

View File

@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.admin.charge
data class GetChargeStatusDetailResponse(
val memberId: Long,
val nickname: String,
val method: String,
val amount: Int,
val datetime: String
)

View File

@ -0,0 +1,12 @@
package kr.co.vividnext.sodalive.admin.charge
import com.querydsl.core.annotations.QueryProjection
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
data class GetChargeStatusQueryDto @QueryProjection constructor(
val date: String,
val appleChargeAmount: Double,
val pgChargeAmount: Int,
val chargeCount: Long,
val paymentGateWay: PaymentGateway
)

View File

@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.admin.charge
data class GetChargeStatusResponse(
val date: String,
val chargeAmount: Int,
val chargeCount: Long,
val pg: String
)

View File

@ -0,0 +1,30 @@
package kr.co.vividnext.sodalive.admin.content
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.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@PreAuthorize("hasRole('ADMIN')")
@RequestMapping("/admin/audio-content")
class AdminContentController(private val service: AdminContentService) {
@GetMapping("/list")
fun getAudioContentList(pageable: Pageable) = ApiResponse.ok(service.getAudioContentList(pageable))
@GetMapping("/search")
fun searchAudioContent(
@RequestParam(value = "search_word") searchWord: String,
pageable: Pageable
) = ApiResponse.ok(service.searchAudioContent(searchWord, pageable))
@PutMapping
fun modifyAudioContent(
@RequestBody request: UpdateAdminContentRequest
) = ApiResponse.ok(service.updateAudioContent(request))
}

View File

@ -0,0 +1,119 @@
package kr.co.vividnext.sodalive.admin.content
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.content.AudioContent
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
import kr.co.vividnext.sodalive.content.hashtag.QAudioContentHashTag.audioContentHashTag
import kr.co.vividnext.sodalive.content.hashtag.QHashTag.hashTag
import kr.co.vividnext.sodalive.content.main.curation.QAudioContentCuration.audioContentCuration
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import java.time.LocalDateTime
@Repository
interface AdminContentRepository : JpaRepository<AudioContent, Long>, AdminAudioContentQueryRepository
interface AdminAudioContentQueryRepository {
fun getAudioContentTotalCount(searchWord: String = ""): Int
fun getAudioContentList(offset: Long, limit: Long, searchWord: String = ""): List<GetAdminContentListItem>
fun getHashTagList(audioContentId: Long): List<String>
}
class AdminAudioContentQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory
) : AdminAudioContentQueryRepository {
override fun getAudioContentTotalCount(searchWord: String): Int {
var where = audioContent.duration.isNotNull
.and(audioContent.member.isNotNull)
.and(audioContent.isActive.isTrue)
if (searchWord.trim().length > 1) {
where = where.and(
audioContent.title.contains(searchWord)
.or(audioContent.member.nickname.contains(searchWord))
)
}
return queryFactory
.select(audioContent.id)
.from(audioContent)
.where(where)
.fetch()
.size
}
override fun getAudioContentList(offset: Long, limit: Long, searchWord: String): List<GetAdminContentListItem> {
var where = audioContent.duration.isNotNull
.and(audioContent.member.isNotNull)
.and(audioContent.isActive.isTrue)
if (searchWord.trim().length > 1) {
where = where.and(
audioContent.title.contains(searchWord)
.or(audioContent.member.nickname.contains(searchWord))
)
}
return queryFactory
.select(
QGetAdminContentListItem(
audioContent.id,
audioContent.title,
audioContent.detail,
audioContentCuration.title,
audioContentCuration.id.nullif(0),
audioContent.coverImage,
audioContent.member!!.nickname,
audioContentTheme.theme,
audioContent.price,
audioContent.isAdult,
audioContent.duration,
audioContent.content,
formattedDateExpression(audioContent.createdAt)
)
)
.from(audioContent)
.leftJoin(audioContent.curation, audioContentCuration)
.innerJoin(audioContent.theme, audioContentTheme)
.where(where)
.offset(offset)
.limit(limit)
.orderBy(audioContent.id.desc())
.fetch()
}
override fun getHashTagList(audioContentId: Long): List<String> {
return queryFactory
.select(hashTag.tag)
.from(audioContentHashTag)
.innerJoin(audioContentHashTag.hashTag, hashTag)
.innerJoin(audioContentHashTag.audioContent, audioContent)
.where(
audioContent.duration.isNotNull
.and(audioContent.member.isNotNull)
.and(audioContentHashTag.audioContent.id.eq(audioContentId))
)
.fetch()
}
private fun formattedDateExpression(
dateTime: DateTimePath<LocalDateTime>,
format: String = "%Y-%m-%d"
): StringTemplate {
return Expressions.stringTemplate(
"DATE_FORMAT({0}, {1})",
Expressions.dateTimeTemplate(
LocalDateTime::class.java,
"CONVERT_TZ({0},{1},{2})",
dateTime,
"UTC",
"Asia/Seoul"
),
format
)
}
}

View File

@ -0,0 +1,121 @@
package kr.co.vividnext.sodalive.admin.content
import kr.co.vividnext.sodalive.admin.content.curation.AdminContentCurationRepository
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
import kr.co.vividnext.sodalive.common.SodaException
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.Pageable
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class AdminContentService(
private val repository: AdminContentRepository,
private val audioContentCloudFront: AudioContentCloudFront,
private val curationRepository: AdminContentCurationRepository,
@Value("\${cloud.aws.cloud-front.host}")
private val coverImageHost: String
) {
fun getAudioContentList(pageable: Pageable): GetAdminContentListResponse {
val totalCount = repository.getAudioContentTotalCount()
val audioContentAndThemeList = repository.getAudioContentList(
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
val audioContentList = audioContentAndThemeList
.asSequence()
.map {
val tags = repository
.getHashTagList(audioContentId = it.audioContentId)
.joinToString(" ") { tag -> tag }
it.tags = tags
it
}
.map {
it.contentUrl = audioContentCloudFront.generateSignedURL(
resourcePath = it.contentUrl,
expirationTime = 1000 * 60 * 60 * (it.remainingTime.split(":")[0].toLong() + 2)
)
it
}
.map {
it.coverImageUrl = "$coverImageHost/${it.coverImageUrl}"
it
}
.toList()
return GetAdminContentListResponse(totalCount, audioContentList)
}
fun searchAudioContent(searchWord: String, pageable: Pageable): GetAdminContentListResponse {
if (searchWord.length < 2) throw SodaException("2글자 이상 입력하세요.")
val totalCount = repository.getAudioContentTotalCount(searchWord)
val audioContentAndThemeList = repository.getAudioContentList(
offset = pageable.offset,
limit = pageable.pageSize.toLong(),
searchWord = searchWord
)
val audioContentList = audioContentAndThemeList
.asSequence()
.map {
val tags = repository
.getHashTagList(audioContentId = it.audioContentId)
.joinToString(" ") { tag -> tag }
it.tags = tags
it
}
.map {
it.contentUrl = audioContentCloudFront.generateSignedURL(
resourcePath = it.contentUrl,
expirationTime = 1000 * 60 * 60 * (it.remainingTime.split(":")[0].toLong() + 2)
)
it
}
.map {
it.coverImageUrl = "$coverImageHost/${it.coverImageUrl}"
it
}
.toList()
return GetAdminContentListResponse(totalCount, audioContentList)
}
@Transactional
fun updateAudioContent(request: UpdateAdminContentRequest) {
val audioContent = repository.findByIdOrNull(id = request.id)
?: throw SodaException("없는 콘텐츠 입니다.")
if (request.isDefaultCoverImage) {
audioContent.coverImage = "profile/default_profile.png"
}
if (request.isActive != null) {
audioContent.isActive = request.isActive
}
if (request.isAdult != null) {
audioContent.isAdult = request.isAdult
}
if (request.isCommentAvailable != null) {
audioContent.isCommentAvailable = request.isCommentAvailable
}
if (request.title != null) {
audioContent.title = request.title
}
if (request.detail != null) {
audioContent.detail = request.detail
}
if (request.curationId != null) {
val curation = curationRepository.findByIdAndActive(id = request.curationId)
audioContent.curation = curation
}
}
}

View File

@ -0,0 +1,26 @@
package kr.co.vividnext.sodalive.admin.content
import com.querydsl.core.annotations.QueryProjection
data class GetAdminContentListResponse(
val totalCount: Int,
val items: List<GetAdminContentListItem>
)
data class GetAdminContentListItem @QueryProjection constructor(
val audioContentId: Long,
val title: String,
val detail: String,
val curationTitle: String?,
val curationId: Long,
var coverImageUrl: String,
val creatorNickname: String,
val theme: String,
val price: Int,
val isAdult: Boolean,
val remainingTime: String,
var contentUrl: String,
val date: String
) {
var tags: String = ""
}

View File

@ -0,0 +1,12 @@
package kr.co.vividnext.sodalive.admin.content
data class UpdateAdminContentRequest(
val id: Long,
val isDefaultCoverImage: Boolean,
val title: String?,
val detail: String?,
val curationId: Long?,
val isAdult: Boolean?,
val isActive: Boolean?,
val isCommentAvailable: Boolean?
)

View File

@ -0,0 +1,37 @@
package kr.co.vividnext.sodalive.admin.content.banner
import kr.co.vividnext.sodalive.common.ApiResponse
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.GetMapping
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.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
@RestController
@RequestMapping("/admin/audio-content/banner")
@PreAuthorize("hasRole('ADMIN')")
class AdminContentBannerController(private val service: AdminContentBannerService) {
@PostMapping
fun createAudioContentMainBanner(
@RequestPart("image") image: MultipartFile,
@RequestPart("request") requestString: String
) = ApiResponse.ok(service.createAudioContentMainBanner(image, requestString))
@PutMapping
fun modifyAudioContentMainBanner(
@RequestPart("image", required = false) image: MultipartFile? = null,
@RequestPart("request") requestString: String
) = ApiResponse.ok(service.updateAudioContentMainBanner(image, requestString))
@PutMapping("/orders")
fun updateBannerOrders(
@RequestBody request: UpdateBannerOrdersRequest
) = ApiResponse.ok(service.updateBannerOrders(request.ids), "수정되었습니다.")
@GetMapping
fun getAudioContentMainBannerList() = ApiResponse.ok(service.getAudioContentMainBannerList())
}

View File

@ -0,0 +1,46 @@
package kr.co.vividnext.sodalive.admin.content.banner
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner
import kr.co.vividnext.sodalive.content.main.banner.QAudioContentBanner.audioContentBanner
import kr.co.vividnext.sodalive.event.QEvent.event
import kr.co.vividnext.sodalive.member.QMember.member
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface AdminContentBannerRepository : JpaRepository<AudioContentBanner, Long>, AdminContentBannerQueryRepository
interface AdminContentBannerQueryRepository {
fun getAudioContentMainBannerList(): List<GetAdminContentBannerResponse>
}
class AdminContentBannerQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) : AdminContentBannerQueryRepository {
override fun getAudioContentMainBannerList(): List<GetAdminContentBannerResponse> {
return queryFactory
.select(
QGetAdminContentBannerResponse(
audioContentBanner.id,
audioContentBanner.type,
audioContentBanner.thumbnailImage.prepend("/").prepend(cloudFrontHost),
audioContentBanner.event.id,
audioContentBanner.event.thumbnailImage,
audioContentBanner.creator.id,
audioContentBanner.creator.nickname,
audioContentBanner.link,
audioContentBanner.isAdult
)
)
.from(audioContentBanner)
.leftJoin(audioContentBanner.event, event)
.leftJoin(audioContentBanner.creator, member)
.where(audioContentBanner.isActive.isTrue)
.orderBy(audioContentBanner.orders.asc())
.fetch()
}
}

View File

@ -0,0 +1,144 @@
package kr.co.vividnext.sodalive.admin.content.banner
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
import kr.co.vividnext.sodalive.event.EventRepository
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile
@Service
class AdminContentBannerService(
private val s3Uploader: S3Uploader,
private val repository: AdminContentBannerRepository,
private val memberRepository: MemberRepository,
private val eventRepository: EventRepository,
private val objectMapper: ObjectMapper,
@Value("\${cloud.aws.s3.bucket}")
private val bucket: String
) {
@Transactional
fun createAudioContentMainBanner(image: MultipartFile, requestString: String) {
val request = objectMapper.readValue(requestString, CreateContentBannerRequest::class.java)
if (request.type == AudioContentBannerType.CREATOR && request.creatorId == null) {
throw SodaException("크리에이터를 선택하세요.")
}
if (request.type == AudioContentBannerType.LINK && request.link == null) {
throw SodaException("링크 url을 입력하세요.")
}
if (request.type == AudioContentBannerType.EVENT && request.eventId == null) {
throw SodaException("이벤트를 선택하세요.")
}
val event = if (request.eventId != null && request.eventId > 0) {
eventRepository.findByIdOrNull(request.eventId)
} else {
null
}
val creator = if (request.creatorId != null && request.creatorId > 0) {
memberRepository.findByIdOrNull(request.creatorId)
} else {
null
}
val audioContentBanner = AudioContentBanner(type = request.type)
audioContentBanner.link = request.link
audioContentBanner.isAdult = request.isAdult
audioContentBanner.event = event
audioContentBanner.creator = creator
repository.save(audioContentBanner)
val fileName = generateFileName()
val imagePath = s3Uploader.upload(
inputStream = image.inputStream,
bucket = bucket,
filePath = "audio_content_banner/${audioContentBanner.id}/$fileName"
)
audioContentBanner.thumbnailImage = imagePath
}
@Transactional
fun updateAudioContentMainBanner(image: MultipartFile?, requestString: String) {
val request = objectMapper.readValue(requestString, UpdateContentBannerRequest::class.java)
val audioContentBanner = repository.findByIdOrNull(request.id)
?: throw SodaException("잘못된 요청입니다.")
if (image != null) {
val fileName = generateFileName()
val imagePath = s3Uploader.upload(
inputStream = image.inputStream,
bucket = bucket,
filePath = "audio_content_banner/${audioContentBanner.id}/$fileName"
)
audioContentBanner.thumbnailImage = imagePath
}
if (request.isAdult != null) {
audioContentBanner.isAdult = request.isAdult
}
if (request.isActive != null) {
audioContentBanner.isActive = request.isActive
}
if (request.type != null) {
audioContentBanner.creator = null
audioContentBanner.event = null
audioContentBanner.link = null
if (request.type == AudioContentBannerType.CREATOR) {
if (request.creatorId != null) {
val creator = memberRepository.findByIdOrNull(request.creatorId)
?: throw SodaException("크리에이터를 선택하세요.")
audioContentBanner.creator = creator
} else {
throw SodaException("크리에이터를 선택하세요.")
}
} else if (request.type == AudioContentBannerType.LINK) {
if (request.link != null) {
audioContentBanner.link = request.link
} else {
throw SodaException("링크 url을 입력하세요.")
}
} else if (request.type == AudioContentBannerType.EVENT) {
if (request.eventId != null) {
val event = eventRepository.findByIdOrNull(request.eventId)
?: throw SodaException("이벤트를 선택하세요.")
audioContentBanner.event = event
} else {
throw SodaException("이벤트를 선택하세요.")
}
}
audioContentBanner.type = request.type
}
}
@Transactional
fun updateBannerOrders(ids: List<Long>) {
for (index in ids.indices) {
val tag = repository.findByIdOrNull(ids[index])
if (tag != null) {
tag.orders = index + 1
}
}
}
fun getAudioContentMainBannerList(): List<GetAdminContentBannerResponse> {
return repository.getAudioContentMainBannerList()
}
}

View File

@ -0,0 +1,11 @@
package kr.co.vividnext.sodalive.admin.content.banner
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
data class CreateContentBannerRequest(
val type: AudioContentBannerType,
val eventId: Long?,
val creatorId: Long?,
val link: String?,
val isAdult: Boolean
)

View File

@ -0,0 +1,16 @@
package kr.co.vividnext.sodalive.admin.content.banner
import com.querydsl.core.annotations.QueryProjection
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
data class GetAdminContentBannerResponse @QueryProjection constructor(
val id: Long,
val type: AudioContentBannerType,
val thumbnailImageUrl: String,
val eventId: Long?,
val eventThumbnailImage: String?,
val creatorId: Long?,
val creatorNickname: String?,
val link: String?,
val isAdult: Boolean
)

View File

@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.admin.content.banner
data class UpdateBannerOrdersRequest(
val ids: List<Long>
)

View File

@ -0,0 +1,13 @@
package kr.co.vividnext.sodalive.admin.content.banner
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
data class UpdateContentBannerRequest(
val id: Long,
val type: AudioContentBannerType?,
val eventId: Long?,
val creatorId: Long?,
val link: String?,
val isAdult: Boolean?,
val isActive: Boolean?
)

View File

@ -0,0 +1,33 @@
package kr.co.vividnext.sodalive.admin.content.curation
import kr.co.vividnext.sodalive.common.ApiResponse
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.GetMapping
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/audio-content/curation")
@PreAuthorize("hasRole('ADMIN')")
class AdminContentCurationController(private val service: AdminContentCurationService) {
@PostMapping
fun createContentCuration(
@RequestBody request: CreateContentCurationRequest
) = ApiResponse.ok(service.createContentCuration(request))
@PutMapping
fun updateContentCuration(
@RequestBody request: UpdateContentCurationRequest
) = ApiResponse.ok(service.updateContentCuration(request))
@PutMapping("/orders")
fun updateContentCurationOrders(
@RequestBody request: UpdateContentCurationOrdersRequest
) = ApiResponse.ok(service.updateContentCurationOrders(request.ids), "수정되었습니다.")
@GetMapping
fun getContentCurationList() = ApiResponse.ok(service.getContentCurationList())
}

View File

@ -0,0 +1,48 @@
package kr.co.vividnext.sodalive.admin.content.curation
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.content.main.curation.AudioContentCuration
import kr.co.vividnext.sodalive.content.main.curation.QAudioContentCuration.audioContentCuration
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface AdminContentCurationRepository :
JpaRepository<AudioContentCuration, Long>,
AdminContentCurationQueryRepository
interface AdminContentCurationQueryRepository {
fun getAudioContentCurationList(): List<GetAdminContentCurationResponse>
fun findByIdAndActive(id: Long): AudioContentCuration?
}
@Repository
class AdminContentCurationQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory
) : AdminContentCurationQueryRepository {
override fun getAudioContentCurationList(): List<GetAdminContentCurationResponse> {
return queryFactory
.select(
QGetAdminContentCurationResponse(
audioContentCuration.id,
audioContentCuration.title,
audioContentCuration.description,
audioContentCuration.isAdult
)
)
.from(audioContentCuration)
.where(audioContentCuration.isActive.isTrue)
.orderBy(audioContentCuration.orders.asc())
.fetch()
}
override fun findByIdAndActive(id: Long): AudioContentCuration? {
return queryFactory
.selectFrom(audioContentCuration)
.where(
audioContentCuration.id.eq(id)
.and(audioContentCuration.isActive.isTrue)
)
.fetchFirst()
}
}

View File

@ -0,0 +1,60 @@
package kr.co.vividnext.sodalive.admin.content.curation
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.main.curation.AudioContentCuration
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class AdminContentCurationService(
private val repository: AdminContentCurationRepository
) {
@Transactional
fun createContentCuration(request: CreateContentCurationRequest) {
repository.save(
AudioContentCuration(
title = request.title,
description = request.description,
isAdult = request.isAdult
)
)
}
@Transactional
fun updateContentCuration(request: UpdateContentCurationRequest) {
val audioContentCuration = repository.findByIdOrNull(id = request.id)
?: throw SodaException("잘못된 요청입니다.")
if (request.title != null) {
audioContentCuration.title = request.title
}
if (request.description != null) {
audioContentCuration.description = request.description
}
if (request.isAdult != null) {
audioContentCuration.isAdult = request.isAdult
}
if (request.isActive != null) {
audioContentCuration.isActive = request.isActive
}
}
@Transactional
fun updateContentCurationOrders(ids: List<Long>) {
for (index in ids.indices) {
val audioContentCuration = repository.findByIdOrNull(ids[index])
if (audioContentCuration != null) {
audioContentCuration.orders = index + 1
}
}
}
fun getContentCurationList(): List<GetAdminContentCurationResponse> {
return repository.getAudioContentCurationList()
}
}

View File

@ -0,0 +1,19 @@
package kr.co.vividnext.sodalive.admin.content.curation
data class CreateContentCurationRequest(
val title: String,
val description: String,
val isAdult: Boolean
)
data class UpdateContentCurationRequest(
val id: Long,
val title: String?,
val description: String?,
val isAdult: Boolean?,
val isActive: Boolean?
)
data class UpdateContentCurationOrdersRequest(
val ids: List<Long>
)

View File

@ -0,0 +1,10 @@
package kr.co.vividnext.sodalive.admin.content.curation
import com.querydsl.core.annotations.QueryProjection
data class GetAdminContentCurationResponse @QueryProjection constructor(
val id: Long,
val title: String,
val description: String,
val isAdult: Boolean
)

View File

@ -0,0 +1,36 @@
package kr.co.vividnext.sodalive.admin.content.theme
import kr.co.vividnext.sodalive.common.ApiResponse
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.DeleteMapping
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.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
@RestController
@RequestMapping("/admin/audio-content/theme")
@PreAuthorize("hasRole('ADMIN')")
class AdminContentThemeController(private val service: AdminContentThemeService) {
@PostMapping
fun enrollmentTheme(
@RequestPart("image") image: MultipartFile,
@RequestPart("request") requestString: String
) = ApiResponse.ok(service.uploadThemeImage(image, requestString), "등록되었습니다.")
@DeleteMapping("/{id}")
fun deleteTheme(@PathVariable id: Long) = ApiResponse.ok(service.deleteTheme(id), "삭제되었습니다.")
@PutMapping("/orders")
fun updateTagOrders(
@RequestBody request: UpdateThemeOrdersRequest
) = ApiResponse.ok(service.updateTagOrders(request.ids), "수정되었습니다.")
@GetMapping
fun getThemes() = ApiResponse.ok(service.getThemes())
}

View File

@ -0,0 +1,48 @@
package kr.co.vividnext.sodalive.admin.content.theme
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.content.theme.GetAudioContentThemeResponse
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme
import kr.co.vividnext.sodalive.content.theme.QGetAudioContentThemeResponse
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface AdminContentThemeRepository : JpaRepository<AudioContentTheme, Long>, AdminContentThemeQueryRepository
interface AdminContentThemeQueryRepository {
fun findIdByTheme(theme: String): Long?
fun getActiveThemes(): List<GetAudioContentThemeResponse>
}
class AdminContentThemeQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) : AdminContentThemeQueryRepository {
override fun findIdByTheme(theme: String): Long? {
return queryFactory
.select(audioContentTheme.id)
.from(audioContentTheme)
.where(audioContentTheme.theme.eq(theme))
.fetchOne()
}
override fun getActiveThemes(): List<GetAudioContentThemeResponse> {
return queryFactory
.select(
QGetAudioContentThemeResponse(
audioContentTheme.id,
audioContentTheme.theme,
audioContentTheme.image.prepend("/").prepend(cloudFrontHost)
)
)
.from(audioContentTheme)
.where(audioContentTheme.isActive.isTrue)
.orderBy(audioContentTheme.orders.asc())
.fetch()
}
}

View File

@ -0,0 +1,70 @@
package kr.co.vividnext.sodalive.admin.content.theme
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.content.theme.GetAudioContentThemeResponse
import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile
@Service
class AdminContentThemeService(
private val s3Uploader: S3Uploader,
private val objectMapper: ObjectMapper,
private val repository: AdminContentThemeRepository,
@Value("\${cloud.aws.s3.bucket}")
private val bucket: String
) {
@Transactional
fun uploadThemeImage(image: MultipartFile, requestString: String) {
val request = objectMapper.readValue(requestString, CreateContentThemeRequest::class.java)
themeExistCheck(request)
val fileName = generateFileName()
val imagePath = s3Uploader.upload(
inputStream = image.inputStream,
bucket = bucket,
filePath = "audio_content_theme/$fileName"
)
return createTheme(request.theme, imagePath)
}
fun createTheme(theme: String, imagePath: String) {
repository.save(AudioContentTheme(theme = theme, image = imagePath))
}
fun themeExistCheck(request: CreateContentThemeRequest) {
repository.findIdByTheme(request.theme)?.let { throw SodaException("이미 등록된 테마 입니다.") }
}
@Transactional
fun deleteTheme(id: Long) {
val theme = repository.findByIdOrNull(id)
?: throw SodaException("잘못된 요청입니다.")
theme.theme = "${theme.theme}_deleted"
theme.isActive = false
}
@Transactional
fun updateTagOrders(ids: List<Long>) {
for (index in ids.indices) {
val theme = repository.findByIdOrNull(ids[index])
if (theme != null) {
theme.orders = index + 1
}
}
}
fun getThemes(): List<GetAudioContentThemeResponse> {
return repository.getActiveThemes()
}
}

View File

@ -0,0 +1,3 @@
package kr.co.vividnext.sodalive.admin.content.theme
data class CreateContentThemeRequest(val theme: String)

View File

@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.admin.content.theme
data class UpdateThemeOrdersRequest(
val ids: List<Long>
)

View File

@ -0,0 +1,32 @@
package kr.co.vividnext.sodalive.admin.event
import kr.co.vividnext.sodalive.common.ApiResponse
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.GetMapping
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("/event/charge")
class AdminChargeEventController(private val service: AdminChargeEventService) {
@PostMapping
@PreAuthorize("hasRole('ADMIN')")
fun createChargeEvent(@RequestBody request: CreateChargeEventRequest): ApiResponse<Any> {
service.createChargeEvent(request)
return ApiResponse.ok(null, "등록되었습니다.")
}
@PutMapping
@PreAuthorize("hasRole('ADMIN')")
fun modifyChargeEvent(@RequestBody request: ModifyChargeEventRequest) = ApiResponse.ok(
service.modifyChargeEvent(request),
"수정되었습니다."
)
@GetMapping("/list")
@PreAuthorize("hasRole('ADMIN')")
fun getChargeEventList() = ApiResponse.ok(service.getChargeEventList())
}

View File

@ -0,0 +1,22 @@
package kr.co.vividnext.sodalive.admin.event
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.admin.event.QChargeEvent.chargeEvent
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface AdminChargeEventRepository : JpaRepository<ChargeEvent, Long>, AdminChargeEventQueryRepository
interface AdminChargeEventQueryRepository {
fun getChargeEventList(): List<ChargeEvent>
}
class AdminChargeEventQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AdminChargeEventQueryRepository {
override fun getChargeEventList(): List<ChargeEvent> {
return queryFactory
.selectFrom(chargeEvent)
.orderBy(chargeEvent.createdAt.desc())
.fetch()
}
}

View File

@ -0,0 +1,100 @@
package kr.co.vividnext.sodalive.admin.event
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.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Service
@Transactional(readOnly = true)
class AdminChargeEventService(private val repository: AdminChargeEventRepository) {
@Transactional
fun createChargeEvent(request: CreateChargeEventRequest): Long {
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
val startDate = LocalDate.parse(request.startDateString, dateTimeFormatter).atTime(0, 0)
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
val endDate = LocalDate.parse(request.endDateString, dateTimeFormatter).atTime(23, 59, 59)
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
val chargeEvent = ChargeEvent(
title = request.title,
startDate = startDate,
endDate = endDate,
availableCount = request.availableCount,
addPercent = request.addPercent / 100f
)
return repository.save(chargeEvent).id!!
}
@Transactional
fun modifyChargeEvent(request: ModifyChargeEventRequest) {
val chargeEvent = repository.findByIdOrNull(request.id)
?: throw SodaException("해당하는 충전이벤트가 없습니다\n다시 시도해 주세요.")
if (request.title != null) {
chargeEvent.title = request.title
}
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
if (request.startDateString != null) {
chargeEvent.startDate = LocalDate.parse(request.startDateString, dateTimeFormatter).atTime(0, 0)
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
}
if (request.endDateString != null) {
chargeEvent.endDate = LocalDate.parse(request.endDateString, dateTimeFormatter).atTime(23, 59, 59)
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
}
if (request.availableCount != null) {
chargeEvent.availableCount = request.availableCount
}
if (request.addPercent != null) {
chargeEvent.addPercent = request.addPercent / 100f
}
if (request.isActive != null) {
chargeEvent.isActive = request.isActive
}
}
fun getChargeEventList(): List<GetChargeEventListResponse> {
return repository.getChargeEventList()
.map {
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
val startDate = it.startDate
.atZone(ZoneId.of("UTC"))
.withZoneSameInstant(ZoneId.of("Asia/Seoul"))
.format(dateTimeFormatter)
val endDate = it.endDate
.atZone(ZoneId.of("UTC"))
.withZoneSameInstant(ZoneId.of("Asia/Seoul"))
.format(dateTimeFormatter)
GetChargeEventListResponse(
id = it.id!!,
title = it.title,
startDate = startDate,
endDate = endDate,
availableCount = it.availableCount,
addPercent = (it.addPercent * 100).toInt(),
isActive = it.isActive
)
}
}
}

View File

@ -0,0 +1,15 @@
package kr.co.vividnext.sodalive.admin.event
import kr.co.vividnext.sodalive.common.BaseEntity
import java.time.LocalDateTime
import javax.persistence.Entity
@Entity
data class ChargeEvent(
var title: String,
var startDate: LocalDateTime,
var endDate: LocalDateTime,
var availableCount: Int,
var addPercent: Float,
var isActive: Boolean = true
) : BaseEntity()

View File

@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.admin.event
data class CreateChargeEventRequest(
val title: String,
val startDateString: String,
val endDateString: String,
val availableCount: Int,
val addPercent: Int
)

View File

@ -0,0 +1,11 @@
package kr.co.vividnext.sodalive.admin.event
data class GetChargeEventListResponse(
val id: Long,
val title: String,
val startDate: String,
val endDate: String,
val availableCount: Int,
val addPercent: Int,
val isActive: Boolean
)

View File

@ -0,0 +1,11 @@
package kr.co.vividnext.sodalive.admin.event
data class ModifyChargeEventRequest(
val id: Long,
val title: String? = null,
val startDateString: String? = null,
val endDateString: String? = null,
val availableCount: Int? = null,
val addPercent: Int? = null,
val isActive: Boolean? = null
)

View File

@ -0,0 +1,39 @@
package kr.co.vividnext.sodalive.admin.explorer
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.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/explorer")
@PreAuthorize("hasRole('ADMIN')")
class AdminExplorerController(private val service: AdminExplorerService) {
@PostMapping
fun createExplorerSection(@RequestBody request: CreateExplorerSectionRequest): ApiResponse<Any> {
service.createExplorerSection(request)
return ApiResponse.ok(null, "등록되었습니다.")
}
@PutMapping
fun updateExplorerSection(@RequestBody request: UpdateExplorerSectionRequest) = ApiResponse.ok(
service.updateExplorerSection(request),
"수정되었습니다."
)
@PutMapping("/orders")
fun updateExplorerSectionOrders(
@RequestBody request: UpdateExplorerSectionOrdersRequest
) = ApiResponse.ok(
service.updateExplorerSectionOrders(request.firstOrders, request.ids),
"수정되었습니다."
)
@GetMapping
fun getExplorerSections(pageable: Pageable) = ApiResponse.ok(service.getExplorerSections(pageable))
}

View File

@ -0,0 +1,43 @@
package kr.co.vividnext.sodalive.admin.explorer
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.explorer.section.ExplorerSection
import kr.co.vividnext.sodalive.explorer.section.QExplorerSection.explorerSection
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface AdminExplorerSectionRepository : JpaRepository<ExplorerSection, Long>, AdminExplorerSectionQueryRepository
interface AdminExplorerSectionQueryRepository {
fun findByTitle(title: String): ExplorerSection?
fun findAllWithPaging(offset: Long, limit: Long): List<ExplorerSection>
fun totalCount(): Int
}
class AdminExplorerSectionQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory
) : AdminExplorerSectionQueryRepository {
override fun findByTitle(title: String): ExplorerSection? {
return queryFactory
.selectFrom(explorerSection)
.where(explorerSection.title.eq(title))
.fetchFirst()
}
override fun findAllWithPaging(offset: Long, limit: Long): List<ExplorerSection> {
return queryFactory
.selectFrom(explorerSection)
.offset(offset)
.limit(limit)
.orderBy(explorerSection.orders.asc())
.fetch()
}
override fun totalCount(): Int {
return queryFactory
.selectFrom(explorerSection)
.fetch()
.size
}
}

View File

@ -0,0 +1,144 @@
package kr.co.vividnext.sodalive.admin.explorer
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.explorer.section.ExplorerSection
import kr.co.vividnext.sodalive.explorer.section.ExplorerSectionCreatorTag
import kr.co.vividnext.sodalive.member.tag.MemberTagRepository
import org.springframework.data.domain.Pageable
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
@Transactional(readOnly = true)
class AdminExplorerService(
private val repository: AdminExplorerSectionRepository,
private val memberTagRepository: MemberTagRepository
) {
@Transactional
fun createExplorerSection(request: CreateExplorerSectionRequest): Long {
if (request.title.isBlank()) throw SodaException("제목을 입력하세요.")
val findExplorerSection = repository.findByTitle(request.title)
if (findExplorerSection != null) throw SodaException("동일한 제목이 있습니다.")
val explorerSection = ExplorerSection(title = request.title, isAdult = request.isAdult)
explorerSection.coloredTitle = request.coloredTitle
explorerSection.color = request.color
val tags = mutableListOf<ExplorerSectionCreatorTag>()
request.tagList.forEach {
val findTag = memberTagRepository.findByTag(it)
if (findTag != null) {
val tag = ExplorerSectionCreatorTag()
tag.explorerSection = explorerSection
tag.tag = findTag
tags.add(tag)
}
}
if (tags.size <= 0) throw SodaException("관심사를 선택하세요.")
explorerSection.tags = tags
return repository.save(explorerSection).id!!
}
@Transactional
fun updateExplorerSection(request: UpdateExplorerSectionRequest) {
if (
request.title == null &&
request.isAdult == null &&
request.tagList == null &&
request.color == null &&
request.coloredTitle == null &&
request.isActive == null
) {
throw SodaException("변경사항이 없습니다.")
}
val explorerSection = repository.findByIdOrNull(request.id)
?: throw SodaException("해당하는 섹션이 없습니다.")
if (request.title != null) {
if (request.title.isBlank()) throw SodaException("올바른 제목을 입력하세요.")
explorerSection.title = request.title
}
if (request.isActive != null) {
explorerSection.isActive = request.isActive
}
if (request.isAdult != null) {
explorerSection.isAdult = request.isAdult
}
if (request.color != null) {
explorerSection.color = request.color
}
if (request.coloredTitle != null) {
explorerSection.coloredTitle = request.coloredTitle
}
if (request.tagList != null) {
val requestTagList = request.tagList.toMutableList()
val tags = explorerSection.tags.filter {
requestTagList.contains(it.tag!!.tag)
requestTagList.remove(it.tag!!.tag)
}.toMutableList()
requestTagList.forEach {
val findTag = memberTagRepository.findByTag(it)
if (findTag != null) {
val tag = ExplorerSectionCreatorTag()
tag.explorerSection = explorerSection
tag.tag = findTag
tags.add(tag)
}
}
if (tags.size <= 0) throw SodaException("관심사를 입력하세요.")
if (tags != explorerSection.tags) {
explorerSection.tags.clear()
explorerSection.tags.addAll(tags)
}
}
}
@Transactional
fun updateExplorerSectionOrders(firstOrders: Int, ids: List<Long>) {
for (index in ids.indices) {
val explorerSection = repository.findByIdOrNull(ids[index])
if (explorerSection != null) {
explorerSection.orders = firstOrders + index
}
}
}
fun getExplorerSections(pageable: Pageable): GetAdminExplorerSectionResponse {
val totalCount = repository.totalCount()
val explorerSectionItemList = repository
.findAllWithPaging(pageable.offset, pageable.pageSize.toLong())
.map {
GetAdminExplorerSectionResponseItem(
id = it.id!!,
title = it.title,
coloredTitle = it.coloredTitle ?: "",
color = it.color ?: "",
isAdult = it.isAdult,
isActive = it.isActive,
tags = it.tags
.asSequence()
.filter { explorerSectionTag -> explorerSectionTag.tag!!.isActive }
.map { explorerSectionTag -> explorerSectionTag.tag!!.tag }
.toList()
)
}
return GetAdminExplorerSectionResponse(
totalCount = totalCount,
explorerSectionItemList = explorerSectionItemList
)
}
}

View File

@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.admin.explorer
data class CreateExplorerSectionRequest(
val title: String,
val isAdult: Boolean,
val tagList: List<String>,
val coloredTitle: String? = null,
val color: String? = null
)

View File

@ -0,0 +1,16 @@
package kr.co.vividnext.sodalive.admin.explorer
data class GetAdminExplorerSectionResponse(
val totalCount: Int,
val explorerSectionItemList: List<GetAdminExplorerSectionResponseItem>
)
data class GetAdminExplorerSectionResponseItem(
val id: Long,
val title: String,
val coloredTitle: String,
val color: String,
val isAdult: Boolean,
val isActive: Boolean,
val tags: List<String>
)

View File

@ -0,0 +1,6 @@
package kr.co.vividnext.sodalive.admin.explorer
data class UpdateExplorerSectionOrdersRequest(
val firstOrders: Int,
val ids: List<Long>
)

View File

@ -0,0 +1,11 @@
package kr.co.vividnext.sodalive.admin.explorer
data class UpdateExplorerSectionRequest(
val id: Long,
val title: String? = null,
val isAdult: Boolean? = null,
val tagList: List<String>? = null,
val coloredTitle: String? = null,
val color: String? = null,
val isActive: Boolean? = null
)

View File

@ -0,0 +1,58 @@
package kr.co.vividnext.sodalive.admin.live
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.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.RequestParam
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
@RestController
@RequestMapping("/admin/live")
class AdminLiveController(private val service: AdminLiveService) {
@GetMapping
@PreAuthorize("hasRole('ADMIN')")
fun getOnAirLive() = ApiResponse.ok(data = service.getLiveList())
@GetMapping("/recommend-creator")
@PreAuthorize("hasRole('ADMIN')")
fun getRecommendCreatorBanner(pageable: Pageable) = ApiResponse.ok(service.getRecommendCreator(pageable))
@PostMapping("/recommend-creator")
fun createRecommendCreatorBanner(
@RequestParam("image") image: MultipartFile,
@RequestParam("creator_id") creatorId: Long,
@RequestParam("start_date") startDate: String,
@RequestParam("end_date") endDate: String,
@RequestParam("is_adult") isAdult: Boolean
) = ApiResponse.ok(
service.createRecommendCreatorBanner(image, creatorId, startDate, endDate, isAdult),
"등록되었습니다."
)
@PutMapping("/recommend-creator")
fun updateRecommendCreatorBanner(
@RequestParam("recommend_creator_banner_id") recommendCreatorBannerId: Long,
@RequestParam("image", required = false) image: MultipartFile?,
@RequestParam("creator_id", required = false) creatorId: Long?,
@RequestParam("start_date", required = false) startDate: String?,
@RequestParam("end_date", required = false) endDate: String?,
@RequestParam("is_adult", required = false) isAdult: Boolean?
) = ApiResponse.ok(
service.updateRecommendCreatorBanner(recommendCreatorBannerId, image, creatorId, startDate, endDate, isAdult),
"수정되었습니다."
)
@PutMapping("/recommend-creator/orders")
fun updateRecommendCreatorBannerOrders(
@RequestBody request: UpdateAdminRecommendCreatorBannerOrdersRequest
) = ApiResponse.ok(
service.updateRecommendCreatorBannerOrders(request.firstOrders, request.ids),
"수정되었습니다."
)
}

View File

@ -0,0 +1,39 @@
package kr.co.vividnext.sodalive.admin.live
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.live.recommend.QRecommendLiveCreatorBanner.recommendLiveCreatorBanner
import kr.co.vividnext.sodalive.live.recommend.RecommendLiveCreatorBanner
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
import kr.co.vividnext.sodalive.member.QMember.member
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Repository
@Repository
class AdminLiveRoomQueryRepository(private val queryFactory: JPAQueryFactory) {
fun getLiveRoomList(): List<LiveRoom> {
return queryFactory
.selectFrom(liveRoom)
.innerJoin(liveRoom.member, member)
.where(liveRoom.isActive.isTrue)
.orderBy(liveRoom.channelName.desc(), liveRoom.beginDateTime.asc())
.fetch()
}
fun getRecommendCreatorTotalCount(): Int {
return queryFactory
.select(recommendLiveCreatorBanner.id)
.from(recommendLiveCreatorBanner)
.fetch()
.size
}
fun getRecommendCreatorList(pageable: Pageable): List<RecommendLiveCreatorBanner> {
return queryFactory
.selectFrom(recommendLiveCreatorBanner)
.offset(pageable.offset)
.limit(pageable.pageSize.toLong())
.orderBy(recommendLiveCreatorBanner.orders.asc(), recommendLiveCreatorBanner.id.desc())
.fetch()
}
}

View File

@ -0,0 +1,240 @@
package kr.co.vividnext.sodalive.admin.live
import com.amazonaws.services.s3.model.ObjectMetadata
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.live.recommend.RecommendLiveCreatorBanner
import kr.co.vividnext.sodalive.live.recommend.RecommendLiveCreatorBannerRepository
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.Pageable
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Service
class AdminLiveService(
private val recommendCreatorBannerRepository: RecommendLiveCreatorBannerRepository,
private val repository: AdminLiveRoomQueryRepository,
private val memberRepository: MemberRepository,
private val s3Uploader: S3Uploader,
@Value("\${cloud.aws.s3.bucket}")
private val bucket: String,
@Value("\${cloud.aws.cloud-front.host}")
private val coverImageHost: String
) {
fun getLiveList(): GetLiveResponse {
return GetLiveResponse(
liveList = repository.getLiveRoomList()
.asSequence()
.map {
GetLiveResponseItem(
id = it.id!!,
title = it.title,
content = it.notice,
managerNickname = it.member!!.nickname,
coverImageUrl = if (it.coverImage!!.startsWith("https://")) {
it.coverImage!!
} else {
"$coverImageHost/${it.coverImage!!}"
},
channelName = it.channelName ?: "",
type = it.type,
password = it.password,
isAdult = it.isAdult
)
}
.toList()
)
}
fun getRecommendCreator(pageable: Pageable): GetAdminRecommendCreatorResponse {
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
val totalCount = repository.getRecommendCreatorTotalCount()
val recommendCreatorList = repository
.getRecommendCreatorList(pageable)
.asSequence()
.map {
GetAdminRecommendCreatorResponseItem(
it.id!!,
"$coverImageHost/${it.image}",
it.creator!!.id!!,
it.creator!!.nickname,
it.startDate
.atZone(ZoneId.of("UTC"))
.withZoneSameInstant(ZoneId.of("Asia/Seoul"))
.toLocalDateTime()
.format(dateTimeFormatter),
it.endDate
.atZone(ZoneId.of("UTC"))
.withZoneSameInstant(ZoneId.of("Asia/Seoul"))
.toLocalDateTime()
.format(dateTimeFormatter),
it.isAdult
)
}
.toList()
return GetAdminRecommendCreatorResponse(
totalCount = totalCount,
recommendCreatorList = recommendCreatorList
)
}
@Transactional
fun createRecommendCreatorBanner(
image: MultipartFile,
creatorId: Long,
startDateString: String,
endDateString: String,
isAdult: Boolean
): Long {
if (creatorId < 1) throw SodaException("올바른 크리에이터를 선택해 주세요.")
val creator = memberRepository.findCreatorByIdOrNull(memberId = creatorId)
?: throw SodaException("올바른 크리에이터를 선택해 주세요.")
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
val startDate = LocalDateTime.parse(startDateString, dateTimeFormatter)
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
val nowDate = LocalDateTime.now()
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
if (startDate < nowDate) throw SodaException("노출 시작일은 현재시간 이후로 설정하셔야 합니다.")
val endDate = LocalDateTime.parse(endDateString, dateTimeFormatter)
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
if (endDate < nowDate) throw SodaException("노출 종료일은 현재시간 이후로 설정하셔야 합니다.")
if (endDate <= startDate) throw SodaException("노출 시작일은 노출 종료일 이전으로 설정하셔야 합니다.")
val recommendCreatorBanner = RecommendLiveCreatorBanner(
startDate = startDate,
endDate = endDate,
isAdult = isAdult
)
recommendCreatorBanner.creator = creator
recommendCreatorBannerRepository.save(recommendCreatorBanner)
val metadata = ObjectMetadata()
metadata.contentLength = image.size
val imagePath = s3Uploader.upload(
inputStream = image.inputStream,
bucket = bucket,
filePath = "recommend_creator_banner/${recommendCreatorBanner.id}/${generateFileName()}",
metadata = metadata
)
recommendCreatorBanner.image = imagePath
return recommendCreatorBanner.id!!
}
@Transactional
fun updateRecommendCreatorBanner(
recommendCreatorBannerId: Long,
image: MultipartFile?,
creatorId: Long?,
startDateString: String?,
endDateString: String?,
isAdult: Boolean?
) {
val recommendCreatorBanner = recommendCreatorBannerRepository.findByIdOrNull(recommendCreatorBannerId)
?: throw SodaException("해당하는 추천라이브가 없습니다. 다시 확인해 주세요.")
if (creatorId != null) {
if (creatorId < 1) throw SodaException("올바른 크리에이터를 선택해 주세요.")
val creator = memberRepository.findCreatorByIdOrNull(memberId = creatorId)
?: throw SodaException("올바른 크리에이터를 선택해 주세요.")
recommendCreatorBanner.creator = creator
}
if (image != null) {
val metadata = ObjectMetadata()
metadata.contentLength = image.size
val imagePath = s3Uploader.upload(
inputStream = image.inputStream,
bucket = bucket,
filePath = "recommend_creator_banner/${recommendCreatorBanner.id}/${generateFileName()}",
metadata = metadata
)
recommendCreatorBanner.image = imagePath
}
if (startDateString != null) {
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
val startDate = LocalDateTime.parse(startDateString, dateTimeFormatter)
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
val endDate = if (endDateString != null) {
LocalDateTime.parse(endDateString, dateTimeFormatter)
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
} else {
null
}
if (endDate != null) {
if (endDate <= startDate) {
throw SodaException("노출 시작일은 노출 종료일 이전으로 설정하셔야 합니다.")
}
recommendCreatorBanner.endDate = endDate
} else {
if (recommendCreatorBanner.endDate <= startDate) {
throw SodaException("노출 시작일은 노출 종료일 이전으로 설정하셔야 합니다.")
}
}
recommendCreatorBanner.startDate = startDate
} else if (endDateString != null) {
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
val endDate = LocalDateTime.parse(endDateString, dateTimeFormatter)
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
if (endDate <= recommendCreatorBanner.startDate) {
throw SodaException("노출 종료일은 노출 시작일 이후로 설정하셔야 합니다.")
}
recommendCreatorBanner.endDate = endDate
}
if (isAdult != null) {
recommendCreatorBanner.isAdult = isAdult
}
}
@Transactional
fun updateRecommendCreatorBannerOrders(firstOrders: Int, ids: List<Long>) {
for (index in ids.indices) {
val recommendCreatorBanner = recommendCreatorBannerRepository.findByIdOrNull(id = ids[index])
if (recommendCreatorBanner != null) {
recommendCreatorBanner.orders = firstOrders + index
}
}
}
}

View File

@ -0,0 +1,16 @@
package kr.co.vividnext.sodalive.admin.live
data class GetAdminRecommendCreatorResponse(
val totalCount: Int,
val recommendCreatorList: List<GetAdminRecommendCreatorResponseItem>
)
data class GetAdminRecommendCreatorResponseItem(
val id: Long,
val image: String,
val creatorId: Long,
val creatorNickname: String,
val startDate: String,
val endDate: String,
val isAdult: Boolean
)

View File

@ -0,0 +1,19 @@
package kr.co.vividnext.sodalive.admin.live
import kr.co.vividnext.sodalive.live.room.LiveRoomType
data class GetLiveResponse(
val liveList: List<GetLiveResponseItem>
)
data class GetLiveResponseItem(
val id: Long,
val title: String,
val content: String,
val managerNickname: String,
val coverImageUrl: String,
val channelName: String,
val type: LiveRoomType,
val password: String?,
val isAdult: Boolean
)

View File

@ -0,0 +1,6 @@
package kr.co.vividnext.sodalive.admin.live
data class UpdateAdminRecommendCreatorBannerOrdersRequest(
val firstOrders: Int,
val ids: List<Long>
)

View File

@ -0,0 +1,43 @@
package kr.co.vividnext.sodalive.admin.member
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.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/admin/member")
@PreAuthorize("hasRole('ADMIN')")
class AdminMemberController(private val service: AdminMemberService) {
@PutMapping
fun updateMember(@RequestBody request: UpdateMemberRequest) = ApiResponse.ok(
service.updateMember(request = request),
"수정되었습니다."
)
@GetMapping("/list")
fun getMemberList(pageable: Pageable) = ApiResponse.ok(service.getMemberList(pageable))
@GetMapping("/search")
fun searchMember(
@RequestParam(value = "search_word") searchWord: String,
pageable: Pageable
) = ApiResponse.ok(service.searchMember(searchWord, pageable))
@GetMapping("/creator/all/list")
fun getCreatorAllList() = ApiResponse.ok(service.getCreatorAllList())
@GetMapping("/creator/list")
fun getCreatorList(pageable: Pageable) = ApiResponse.ok(service.getCreatorList(pageable))
@GetMapping("/creator/search")
fun searchCreator(
@RequestParam(value = "search_word") searchWord: String,
pageable: Pageable
) = ApiResponse.ok(service.searchCreator(searchWord, pageable))
}

View File

@ -0,0 +1,112 @@
package kr.co.vividnext.sodalive.admin.member
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.QMember.member
import org.springframework.data.jpa.repository.JpaRepository
interface AdminMemberRepository : JpaRepository<Member, Long>, AdminMemberQueryRepository
interface AdminMemberQueryRepository {
fun getMemberTotalCount(role: MemberRole? = null): Int
fun getMemberList(offset: Long, limit: Long, role: MemberRole? = null): List<Member>
fun searchMember(searchWord: String, offset: Long, limit: Long, role: MemberRole? = null): List<Member>
fun searchMemberTotalCount(searchWord: String, role: MemberRole? = null): Int
fun getCreatorAllList(): List<GetAdminCreatorAllListResponse>
}
class AdminMemberQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AdminMemberQueryRepository {
override fun getMemberList(offset: Long, limit: Long, role: MemberRole?): List<Member> {
return queryFactory
.selectFrom(member)
.where(
member.role.ne(MemberRole.ADMIN)
.and(
if (role != null) {
member.role.eq(role)
} else {
null
}
)
)
.offset(offset)
.limit(limit)
.orderBy(member.id.desc())
.fetch()
}
override fun getMemberTotalCount(role: MemberRole?): Int {
return queryFactory
.select(member.id)
.from(member)
.where(
member.role.ne(MemberRole.ADMIN)
.and(
if (role != null) {
member.role.eq(role)
} else {
null
}
)
)
.fetch()
.size
}
override fun searchMember(searchWord: String, offset: Long, limit: Long, role: MemberRole?): List<Member> {
return queryFactory
.selectFrom(member)
.where(
member.nickname.contains(searchWord)
.or(member.email.contains(searchWord))
.and(
if (role != null) {
member.role.eq(role)
} else {
null
}
)
)
.offset(offset)
.limit(limit)
.orderBy(member.id.desc())
.fetch()
}
override fun searchMemberTotalCount(searchWord: String, role: MemberRole?): Int {
return queryFactory
.select(member.id)
.from(member)
.where(
member.nickname.contains(searchWord)
.or(member.email.contains(searchWord))
.and(
if (role != null) {
member.role.eq(role)
} else {
null
}
)
)
.fetch()
.size
}
override fun getCreatorAllList(): List<GetAdminCreatorAllListResponse> {
return queryFactory
.select(
QGetAdminCreatorAllListResponse(
member.id,
member.nickname
)
)
.from(member)
.where(
member.role.eq(MemberRole.CREATOR)
.and(member.isActive.isTrue)
)
.fetch()
}
}

View File

@ -0,0 +1,136 @@
package kr.co.vividnext.sodalive.admin.member
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.Pageable
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Service
class AdminMemberService(
private val repository: AdminMemberRepository,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) {
@Transactional
fun updateMember(request: UpdateMemberRequest) {
val member = repository.findByIdOrNull(request.id)
?: throw SodaException("해당 유저가 없습니다.")
if (member.role != request.userType) {
member.role = request.userType
}
}
fun getMemberList(pageable: Pageable): GetAdminMemberListResponse {
val totalCount = repository.getMemberTotalCount()
val memberList = processMemberListToGetAdminMemberListResponseItemList(
memberList = repository.getMemberList(
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
)
return GetAdminMemberListResponse(totalCount, memberList)
}
fun searchMember(searchWord: String, pageable: Pageable): GetAdminMemberListResponse {
if (searchWord.length < 2) throw SodaException("2글자 이상 입력하세요.")
val totalCount = repository.searchMemberTotalCount(searchWord = searchWord)
val memberList = processMemberListToGetAdminMemberListResponseItemList(
memberList = repository.searchMember(
searchWord = searchWord,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
)
return GetAdminMemberListResponse(totalCount, memberList)
}
fun getCreatorList(pageable: Pageable): GetAdminMemberListResponse {
val totalCount = repository.getMemberTotalCount(role = MemberRole.CREATOR)
val creatorList = processMemberListToGetAdminMemberListResponseItemList(
memberList = repository.getMemberList(
offset = pageable.offset,
limit = pageable.pageSize.toLong(),
role = MemberRole.CREATOR
)
)
return GetAdminMemberListResponse(totalCount, creatorList)
}
fun searchCreator(searchWord: String, pageable: Pageable): GetAdminMemberListResponse {
if (searchWord.length < 2) throw SodaException("2글자 이상 입력하세요.")
val totalCount = repository.searchMemberTotalCount(searchWord = searchWord, role = MemberRole.CREATOR)
val creatorList = processMemberListToGetAdminMemberListResponseItemList(
memberList = repository.searchMember(
searchWord = searchWord,
offset = pageable.offset,
limit = pageable.pageSize.toLong(),
role = MemberRole.CREATOR
)
)
return GetAdminMemberListResponse(totalCount, creatorList)
}
private fun processMemberListToGetAdminMemberListResponseItemList(
memberList: List<Member>
): List<GetAdminMemberListResponseItem> {
return memberList
.asSequence()
.map {
val userType = when (it.role) {
MemberRole.ADMIN -> "관리자"
MemberRole.USER -> "일반회원"
MemberRole.CREATOR -> "크리에이터"
MemberRole.AGENT -> "에이전트"
MemberRole.BOT -> ""
}
val signUpDate = it.createdAt!!
.atZone(ZoneId.of("UTC"))
.withZoneSameInstant(ZoneId.of("Asia/Seoul"))
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
val signOutDate = if (it.signOutReasons.isNotEmpty()) {
it.signOutReasons.last().createdAt!!
.atZone(ZoneId.of("UTC"))
.withZoneSameInstant(ZoneId.of("Asia/Seoul"))
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
} else {
""
}
GetAdminMemberListResponseItem(
id = it.id!!,
email = it.email,
nickname = it.nickname,
profileUrl = if (it.profileImage != null) {
"$cloudFrontHost/${it.profileImage}"
} else {
"$cloudFrontHost/profile/default-profile.png"
},
userType = userType,
container = it.container,
auth = it.auth != null,
signUpDate = signUpDate,
signOutDate = signOutDate,
isActive = it.isActive
)
}
.toList()
}
fun getCreatorAllList(): List<GetAdminCreatorAllListResponse> {
return repository.getCreatorAllList()
}
}

View File

@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.admin.member
import com.querydsl.core.annotations.QueryProjection
data class GetAdminCreatorAllListResponse @QueryProjection constructor(
val id: Long,
val nickname: String
)

View File

@ -0,0 +1,19 @@
package kr.co.vividnext.sodalive.admin.member
data class GetAdminMemberListResponse(
val totalCount: Int,
val items: List<GetAdminMemberListResponseItem>
)
data class GetAdminMemberListResponseItem(
val id: Long,
val email: String,
val nickname: String,
val profileUrl: String,
val userType: String,
val container: String,
val auth: Boolean,
val signUpDate: String,
val signOutDate: String,
val isActive: Boolean
)

View File

@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.admin.member
import kr.co.vividnext.sodalive.member.MemberRole
data class UpdateMemberRequest(
val id: Long,
val userType: MemberRole
)

View File

@ -0,0 +1,39 @@
package kr.co.vividnext.sodalive.admin.member.tag
import kr.co.vividnext.sodalive.common.ApiResponse
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.DeleteMapping
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.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
@RestController
@RequestMapping("/admin/member/tag")
@PreAuthorize("hasRole('ADMIN')")
class AdminMemberTagController(private val service: AdminMemberTagService) {
@PostMapping
fun enrollmentCreatorTag(
@RequestPart("image") image: MultipartFile,
@RequestPart("request") requestString: String
) = ApiResponse.ok(service.uploadTagImage(image, requestString), "등록되었습니다.")
@DeleteMapping("/{id}")
fun deleteCreatorTag(@PathVariable id: Long) = ApiResponse.ok(service.deleteTag(id), "삭제되었습니다.")
@PutMapping("/{id}")
fun modifyCreatorTag(
@PathVariable id: Long,
@RequestPart("image") image: MultipartFile?,
@RequestPart("request") requestString: String
) = ApiResponse.ok(service.modifyTag(id, image, requestString), "수정되었습니다.")
@PutMapping("/orders")
fun updateTagOrders(
@RequestBody request: UpdateTagOrdersRequest
) = ApiResponse.ok(service.updateTagOrders(request.ids), "수정되었습니다.")
}

View File

@ -0,0 +1,22 @@
package kr.co.vividnext.sodalive.admin.member.tag
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.member.tag.CreatorTag
import kr.co.vividnext.sodalive.member.tag.QCreatorTag.creatorTag
import org.springframework.data.jpa.repository.JpaRepository
interface AdminMemberTagRepository : JpaRepository<CreatorTag, Long>, AdminMemberTagQueryRepository
interface AdminMemberTagQueryRepository {
fun findByTag(tag: String): Long?
}
class AdminMemberTagQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AdminMemberTagQueryRepository {
override fun findByTag(tag: String): Long? {
return queryFactory
.select(creatorTag.id)
.from(creatorTag)
.where(creatorTag.tag.eq(tag))
.fetchFirst()
}
}

View File

@ -0,0 +1,83 @@
package kr.co.vividnext.sodalive.admin.member.tag
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.tag.CreatorTag
import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile
@Service
class AdminMemberTagService(
private val repository: AdminMemberTagRepository,
private val s3Uploader: S3Uploader,
private val objectMapper: ObjectMapper,
@Value("\${cloud.aws.s3.bucket}")
private val bucket: String
) {
@Transactional
fun uploadTagImage(image: MultipartFile, requestString: String) {
val request = objectMapper.readValue(requestString, CreateMemberTagRequest::class.java)
tagExistCheck(request)
val fileName = generateFileName()
val imagePath = s3Uploader.upload(
inputStream = image.inputStream,
bucket = bucket,
filePath = "creator_tag/$fileName"
)
return createTag(request.tag, imagePath)
}
private fun tagExistCheck(request: CreateMemberTagRequest) {
repository.findByTag(request.tag)?.let { throw SodaException("이미 등록된 태그 입니다.") }
}
private fun createTag(tag: String, imagePath: String) {
repository.save(CreatorTag(tag, imagePath))
}
@Transactional
fun deleteTag(id: Long) {
val creatorTag = repository.findByIdOrNull(id)
?: throw SodaException("잘못된 요청입니다.")
creatorTag.tag = "${creatorTag.tag}_deleted"
creatorTag.isActive = false
}
@Transactional
fun modifyTag(id: Long, image: MultipartFile?, requestString: String) {
val creatorTag = repository.findByIdOrNull(id)
?: throw SodaException("잘못된 요청입니다.")
val request = objectMapper.readValue(requestString, CreateMemberTagRequest::class.java)
creatorTag.tag = request.tag
if (image != null) {
val fileName = generateFileName()
val imagePath = s3Uploader.upload(
inputStream = image.inputStream,
bucket = bucket,
filePath = "creator_tag/$fileName"
)
creatorTag.image = imagePath
}
}
@Transactional
fun updateTagOrders(ids: List<Long>) {
for (index in ids.indices) {
val tag = repository.findByIdOrNull(ids[index])
if (tag != null) {
tag.orders = index + 1
}
}
}
}

View File

@ -0,0 +1,3 @@
package kr.co.vividnext.sodalive.admin.member.tag
data class CreateMemberTagRequest(val tag: String)

View File

@ -0,0 +1,3 @@
package kr.co.vividnext.sodalive.admin.member.tag
data class UpdateTagOrdersRequest(val ids: List<Long>)

View File

@ -0,0 +1,171 @@
package kr.co.vividnext.sodalive.agora
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.util.TreeMap
class AccessToken(
var appId: String,
private val appCertificate: String,
val channelName: String,
private val uid: String,
var crcChannelName: Int = 0,
private var crcUid: Int = 0,
val message: PrivilegeMessage = PrivilegeMessage()
) {
private lateinit var signature: ByteArray
private lateinit var messageRawContent: ByteArray
enum class Privileges(value: Int) {
JoinChannel(1),
PublishAudioStream(2),
PublishVideoStream(3),
PublishDataStream(4), // For RTM only
RtmLogin(1000);
var intValue: Short
init {
intValue = value.toShort()
}
}
@Throws(Exception::class)
fun build(): String {
if (!AgoraUtils.isUUID(appId)) {
return ""
}
if (!AgoraUtils.isUUID(appCertificate)) {
return ""
}
messageRawContent = AgoraUtils.pack(message)
signature = generateSignature(
appCertificate,
appId,
channelName,
uid,
messageRawContent
)
crcChannelName = AgoraUtils.crc32(channelName)
crcUid = AgoraUtils.crc32(uid)
val packContent = PackContent(signature, crcChannelName, crcUid, messageRawContent)
val content: ByteArray = AgoraUtils.pack(packContent)
return getVersion() + appId + AgoraUtils.base64Encode(content)
}
fun addPrivilege(privilege: Privileges, expireTimestamp: Int) {
message.messages[privilege.intValue] = expireTimestamp
}
private fun getVersion(): String {
return VER
}
@Throws(java.lang.Exception::class)
fun generateSignature(
appCertificate: String,
appID: String,
channelName: String,
uid: String,
message: ByteArray
): ByteArray {
val baos = ByteArrayOutputStream()
try {
baos.write(appID.toByteArray())
baos.write(channelName.toByteArray())
baos.write(uid.toByteArray())
baos.write(message)
} catch (e: IOException) {
e.printStackTrace()
}
return AgoraUtils.hmacSign(appCertificate, baos.toByteArray())
}
fun fromString(token: String): Boolean {
if (getVersion() != token.substring(0, AgoraUtils.VERSION_LENGTH)) {
return false
}
try {
appId = token.substring(AgoraUtils.VERSION_LENGTH, AgoraUtils.VERSION_LENGTH + AgoraUtils.APP_ID_LENGTH)
val packContent = PackContent()
AgoraUtils.unpack(
AgoraUtils.base64Decode(
token.substring(
AgoraUtils.VERSION_LENGTH + AgoraUtils.APP_ID_LENGTH,
token.length
)
),
packContent
)
signature = packContent.signature
crcChannelName = packContent.crcChannelName
crcUid = packContent.crcUid
messageRawContent = packContent.rawMessage
AgoraUtils.unpack(messageRawContent, message)
} catch (e: java.lang.Exception) {
e.printStackTrace()
return false
}
return true
}
class PrivilegeMessage : PackableEx {
var salt: Int
var ts: Int
var messages: TreeMap<Short, Int>
override fun marshal(out: ByteBuf): ByteBuf {
return out.put(salt).put(ts).putIntMap(messages)
}
override fun unmarshal(input: ByteBuf) {
salt = input.readInt()
ts = input.readInt()
messages = input.readIntMap()
}
init {
salt = AgoraUtils.randomInt()
ts = AgoraUtils.getTimestamp() + 24 * 3600
messages = TreeMap()
}
}
class PackContent() : PackableEx {
var signature: ByteArray = byteArrayOf()
var crcChannelName = 0
var crcUid = 0
var rawMessage: ByteArray = byteArrayOf()
constructor(signature: ByteArray, crcChannelName: Int, crcUid: Int, rawMessage: ByteArray) : this() {
this.signature = signature
this.crcChannelName = crcChannelName
this.crcUid = crcUid
this.rawMessage = rawMessage
}
override fun marshal(out: ByteBuf): ByteBuf {
return out
.put(signature)
.put(crcChannelName)
.put(crcUid).put(rawMessage)
}
override fun unmarshal(input: ByteBuf) {
signature = input.readBytes()
crcChannelName = input.readInt()
crcUid = input.readInt()
rawMessage = input.readBytes()
}
}
companion object {
const val VER = "006"
}
}

View File

@ -0,0 +1,72 @@
package kr.co.vividnext.sodalive.agora
import org.apache.commons.codec.binary.Base64
import java.security.InvalidKeyException
import java.security.NoSuchAlgorithmException
import java.security.SecureRandom
import java.util.Date
import java.util.zip.CRC32
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
object AgoraUtils {
const val HMAC_SHA256_LENGTH: Long = 32
const val VERSION_LENGTH = 3
const val APP_ID_LENGTH = 32
@Throws(InvalidKeyException::class, NoSuchAlgorithmException::class)
fun hmacSign(keyString: String, msg: ByteArray?): ByteArray {
val keySpec = SecretKeySpec(keyString.toByteArray(), "HmacSHA256")
val mac = Mac.getInstance("HmacSHA256")
mac.init(keySpec)
return mac.doFinal(msg)
}
fun pack(packableEx: PackableEx): ByteArray {
val buffer = ByteBuf()
packableEx.marshal(buffer)
return buffer.asBytes()
}
fun unpack(data: ByteArray?, packableEx: PackableEx) {
val buffer = ByteBuf(data!!)
packableEx.unmarshal(buffer)
}
fun base64Encode(data: ByteArray?): String {
val encodedBytes: ByteArray = Base64.encodeBase64(data)
return String(encodedBytes)
}
fun base64Decode(data: String): ByteArray {
return Base64.decodeBase64(data.toByteArray())
}
fun crc32(data: String): Int {
// get bytes from string
val bytes = data.toByteArray()
return crc32(bytes)
}
fun crc32(bytes: ByteArray?): Int {
val checksum = CRC32()
checksum.update(bytes)
return checksum.value.toInt()
}
fun getTimestamp(): Int {
return (Date().time / 1000).toInt()
}
fun randomInt(): Int {
return SecureRandom().nextInt()
}
fun isUUID(uuid: String): Boolean {
return if (uuid.length != 32) {
false
} else {
uuid.matches("\\p{XDigit}+".toRegex())
}
}
}

View File

@ -0,0 +1,111 @@
package kr.co.vividnext.sodalive.agora
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.TreeMap
class ByteBuf() {
private var buffer = ByteBuffer.allocate(1024).order(ByteOrder.LITTLE_ENDIAN)
constructor(bytes: ByteArray) : this() {
this.buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)
}
fun asBytes(): ByteArray {
val out = ByteArray(buffer.position())
buffer.rewind()
buffer[out, 0, out.size]
return out
}
// packUint16
fun put(v: Short): ByteBuf {
buffer.putShort(v)
return this
}
fun put(v: ByteArray): ByteBuf {
put(v.size.toShort())
buffer.put(v)
return this
}
// packUint32
fun put(v: Int): ByteBuf {
buffer.putInt(v)
return this
}
fun put(v: Long): ByteBuf {
buffer.putLong(v)
return this
}
fun put(v: String): ByteBuf {
return put(v.toByteArray())
}
fun put(extra: TreeMap<Short, String>): ByteBuf {
put(extra.size.toShort())
for ((key, value) in extra.entries) {
put(key)
put(value)
}
return this
}
fun putIntMap(extra: TreeMap<Short, Int>): ByteBuf {
put(extra.size.toShort())
for ((key, value) in extra.entries) {
put(key)
put(value)
}
return this
}
fun readShort(): Short {
return buffer.short
}
fun readInt(): Int {
return buffer.int
}
fun readBytes(): ByteArray {
val length = readShort()
val bytes = ByteArray(length.toInt())
buffer[bytes]
return bytes
}
fun readString(): String {
val bytes = readBytes()
return String(bytes)
}
fun readMap(): TreeMap<Short, String> {
val map = TreeMap<Short, String>()
val length = readShort()
for (i in 0 until length) {
val k = readShort()
val v = readString()
map[k] = v
}
return map
}
fun readIntMap(): TreeMap<Short, Int> {
val map = TreeMap<Short, Int>()
val length = readShort()
for (i in 0 until length) {
val k = readShort()
val v = readInt()
map[k] = v
}
return map
}
}

View File

@ -0,0 +1,256 @@
package kr.co.vividnext.sodalive.agora
import org.apache.commons.codec.binary.Base64
import org.apache.commons.codec.binary.Hex
import java.util.TreeMap
class DynamicKey5 {
lateinit var content: DynamicKey5Content
fun fromString(key: String): Boolean {
if (key.substring(0, 3) != version) {
return false
}
val rawContent: ByteArray = Base64().decode(key.substring(3))
if (rawContent.isEmpty()) {
return false
}
content = DynamicKey5Content()
val buffer = ByteBuf(rawContent)
content.unmarshall(buffer)
return true
}
companion object {
const val version = "005"
const val noUpload = "0"
const val audioVideoUpload = "3"
// ServiceType
const val MEDIA_CHANNEL_SERVICE: Short = 1
const val RECORDING_SERVICE: Short = 2
const val PUBLIC_SHARING_SERVICE: Short = 3
const val IN_CHANNEL_PERMISSION: Short = 4
// InChannelPermissionKey
const val ALLOW_UPLOAD_IN_CHANNEL: Short = 1
@Throws(Exception::class)
fun generateSignature(
appCertificate: String,
service: Short,
appID: String,
unixTs: Int,
salt: Int,
channelName: String,
uid: Long,
expiredTs: Int,
extra: TreeMap<Short, String>
): String {
// decode hex to avoid case problem
val hex = Hex()
val rawAppID: ByteArray = hex.decode(appID.toByteArray())
val rawAppCertificate: ByteArray = hex.decode(appCertificate.toByteArray())
val m = Message(
service,
rawAppID,
unixTs,
salt,
channelName,
(uid and 0xFFFFFFFFL).toInt(),
expiredTs,
extra
)
val toSign: ByteArray = pack(m)
return String(Hex.encodeHex(DynamicKeyUtil.encodeHMAC(rawAppCertificate, toSign), false))
}
@Throws(java.lang.Exception::class)
fun generateDynamicKey(
appID: String,
appCertificate: String,
channel: String,
ts: Int,
salt: Int,
uid: Long,
expiredTs: Int,
extra: TreeMap<Short, String>,
service: Short
): String {
val signature = generateSignature(appCertificate, service, appID, ts, salt, channel, uid, expiredTs, extra)
val content =
DynamicKey5Content(service, signature, Hex().decode(appID.toByteArray()), ts, salt, expiredTs, extra)
val bytes: ByteArray = pack(content)
val encoded = Base64().encode(bytes)
val base64 = String(encoded)
return version + base64
}
private fun pack(content: Packable): ByteArray {
val buffer = ByteBuf()
content.marshal(buffer)
return buffer.asBytes()
}
@Throws(Exception::class)
fun generatePublicSharingKey(
appID: String,
appCertificate: String,
channel: String,
ts: Int,
salt: Int,
uid: Long,
expiredTs: Int
) = generateDynamicKey(
appID,
appCertificate,
channel,
ts,
salt,
uid,
expiredTs,
TreeMap(),
PUBLIC_SHARING_SERVICE
)
@Throws(Exception::class)
fun generateRecordingKey(
appID: String,
appCertificate: String,
channel: String,
ts: Int,
salt: Int,
uid: Long,
expiredTs: Int
) = generateDynamicKey(
appID,
appCertificate,
channel,
ts,
salt,
uid,
expiredTs,
TreeMap(),
RECORDING_SERVICE
)
@Throws(Exception::class)
fun generateMediaChannelKey(
appID: String,
appCertificate: String,
channel: String,
ts: Int,
salt: Int,
uid: Long,
expiredTs: Int
) = generateDynamicKey(
appID,
appCertificate,
channel,
ts,
salt,
uid,
expiredTs,
TreeMap(),
MEDIA_CHANNEL_SERVICE
)
@Throws(Exception::class)
fun generateInChannelPermissionKey(
appID: String,
appCertificate: String,
channel: String,
ts: Int,
salt: Int,
uid: Long,
expiredTs: Int,
permission: String
): String {
val extra = TreeMap<Short, String>()
extra[ALLOW_UPLOAD_IN_CHANNEL] = permission
return generateDynamicKey(
appID,
appCertificate,
channel,
ts,
salt,
uid,
expiredTs,
extra,
IN_CHANNEL_PERMISSION
)
}
internal class Message(
var serviceType: Short,
var appID: ByteArray,
var unixTs: Int,
var salt: Int,
var channelName: String,
var uid: Int,
var expiredTs: Int,
var extra: TreeMap<Short, String>
) : Packable {
override fun marshal(out: ByteBuf): ByteBuf {
return out
.put(serviceType)
.put(appID)
.put(unixTs)
.put(salt)
.put(channelName)
.put(uid)
.put(expiredTs)
.put(extra)
}
}
class DynamicKey5Content() : Packable {
var serviceType: Short = 0
var signature: String? = null
var appID: ByteArray = byteArrayOf()
var unixTs = 0
var salt = 0
var expiredTs = 0
var extra: TreeMap<Short, String>? = null
constructor(
serviceType: Short,
signature: String?,
appID: ByteArray,
unixTs: Int,
salt: Int,
expiredTs: Int,
extra: TreeMap<Short, String>
) : this() {
this.serviceType = serviceType
this.signature = signature
this.appID = appID
this.unixTs = unixTs
this.salt = salt
this.expiredTs = expiredTs
this.extra = extra
}
override fun marshal(out: ByteBuf): ByteBuf {
return out
.put(serviceType)
.put(signature!!)
.put(appID)
.put(unixTs)
.put(salt)
.put(expiredTs)
.put(extra!!)
}
fun unmarshall(input: ByteBuf) {
serviceType = input.readShort()
signature = input.readString()
appID = input.readBytes()
unixTs = input.readInt()
salt = input.readInt()
expiredTs = input.readInt()
extra = input.readMap()
}
}
}
}

View File

@ -0,0 +1,33 @@
package kr.co.vividnext.sodalive.agora
import java.security.InvalidKeyException
import java.security.NoSuchAlgorithmException
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
/**
* Created by hefeng on 15/8/10.
* Util to generate Agora media dynamic key.
*/
object DynamicKeyUtil {
@Throws(NoSuchAlgorithmException::class, InvalidKeyException::class)
fun encodeHMAC(key: String, message: ByteArray?): ByteArray? {
return encodeHMAC(key.toByteArray(), message)
}
@Throws(NoSuchAlgorithmException::class, InvalidKeyException::class)
fun encodeHMAC(key: ByteArray?, message: ByteArray?): ByteArray? {
val keySpec = SecretKeySpec(key, "HmacSHA1")
val mac = Mac.getInstance("HmacSHA1")
mac.init(keySpec)
return mac.doFinal(message)
}
fun bytesToHex(`in`: ByteArray): String {
val builder = StringBuilder()
for (b in `in`) {
builder.append(String.format("%02x", b))
}
return builder.toString()
}
}

View File

@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.agora
interface Packable {
fun marshal(out: ByteBuf): ByteBuf
}

View File

@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.agora
interface PackableEx : Packable {
fun unmarshal(input: ByteBuf)
}

View File

@ -0,0 +1,96 @@
package kr.co.vividnext.sodalive.agora
import org.springframework.stereotype.Component
@Component
class RtcTokenBuilder {
/**
* Builds an RTC token using an int uid.
*
* @param appId The App ID issued to you by Agora.
* @param appCertificate Certificate of the application that you registered in
* the Agora Dashboard.
* @param channelName The unique channel name for the AgoraRTC session in the string format. The string length must be less than 64 bytes. Supported character scopes are:
*
* * The 26 lowercase English letters: a to z.
* * The 26 uppercase English letters: A to Z.
* * The 10 digits: 0 to 9.
* * The space.
* * "!", "#", "$", "%", "&", "(", ")", "+", "-", ":", ";", "<", "=", ".", ">", "?", "@", "[", "]", "^", "_", " {", "}", "|", "~", ",".
*
* @param uid User ID. A 32-bit unsigned integer with a value ranging from
* 1 to (2^32-1).
* @param role The user role.
*
* * Role_Publisher = 1: RECOMMENDED. Use this role for a voice/video call or a live broadcast.
* * Role_Subscriber = 2: ONLY use this role if your live-broadcast scenario requires authentication for [Hosting-in](https://docs.agora.io/en/Agora%20Platform/terms?platform=All%20Platforms#hosting-in). In order for this role to take effect, please contact our support team to enable authentication for Hosting-in for you. Otherwise, Role_Subscriber still has the same privileges as Role_Publisher.
*
* @param privilegeTs Represented by the number of seconds elapsed since 1/1/1970.
* If, for example, you want to access the Agora Service within 10 minutes
* after the token is generated, set expireTimestamp as the current time stamp
* + 600 (seconds).
*/
fun buildTokenWithUid(
appId: String,
appCertificate: String,
channelName: String,
uid: Int,
privilegeTs: Int
): String {
val account = if (uid == 0) "" else uid.toString()
return buildTokenWithUserAccount(
appId,
appCertificate,
channelName,
account,
privilegeTs
)
}
/**
* Builds an RTC token using a string userAccount.
*
* @param appId The App ID issued to you by Agora.
* @param appCertificate Certificate of the application that you registered in
* the Agora Dashboard.
* @param channelName The unique channel name for the AgoraRTC session in the string format. The string length must be less than 64 bytes. Supported character scopes are:
*
* * The 26 lowercase English letters: a to z.
* * The 26 uppercase English letters: A to Z.
* * The 10 digits: 0 to 9.
* * The space.
* * "!", "#", "$", "%", "&", "(", ")", "+", "-", ":", ";", "<", "=", ".", ">", "?", "@", "[", "]", "^", "_", " {", "}", "|", "~", ",".
*
* @param account The user account.
* @param role The user role.
*
* * Role_Publisher = 1: RECOMMENDED. Use this role for a voice/video call or a live broadcast.
* * Role_Subscriber = 2: ONLY use this role if your live-broadcast scenario requires authentication for [Hosting-in](https://docs.agora.io/en/Agora%20Platform/terms?platform=All%20Platforms#hosting-in). In order for this role to take effect, please contact our support team to enable authentication for Hosting-in for you. Otherwise, Role_Subscriber still has the same privileges as Role_Publisher.
*
* @param privilegeTs represented by the number of seconds elapsed since 1/1/1970.
* If, for example, you want to access the Agora Service within 10 minutes
* after the token is generated, set expireTimestamp as the current time stamp
* + 600 (seconds).
*/
fun buildTokenWithUserAccount(
appId: String,
appCertificate: String,
channelName: String,
account: String,
privilegeTs: Int
): String {
// Assign appropriate access privileges to each role.
val builder = AccessToken(appId, appCertificate, channelName, account)
builder.addPrivilege(AccessToken.Privileges.JoinChannel, privilegeTs)
builder.addPrivilege(AccessToken.Privileges.PublishAudioStream, privilegeTs)
builder.addPrivilege(AccessToken.Privileges.PublishVideoStream, privilegeTs)
builder.addPrivilege(AccessToken.Privileges.PublishDataStream, privilegeTs)
return try {
builder.build()
} catch (e: Exception) {
e.printStackTrace()
""
}
}
}

View File

@ -0,0 +1,31 @@
package kr.co.vividnext.sodalive.agora
import kr.co.vividnext.sodalive.agora.AccessToken.Privileges
import org.springframework.stereotype.Component
@Component
class RtmTokenBuilder {
lateinit var mTokenCreator: AccessToken
@Throws(Exception::class)
fun buildToken(
appId: String,
appCertificate: String,
uid: String,
privilegeTs: Int
): String {
mTokenCreator = AccessToken(appId, appCertificate, uid, "")
mTokenCreator.addPrivilege(Privileges.RtmLogin, privilegeTs)
return mTokenCreator.build()
}
fun setPrivilege(privilege: Privileges?, expireTs: Int) {
mTokenCreator.addPrivilege(privilege!!, expireTs)
}
fun initTokenBuilder(originToken: String?): Boolean {
mTokenCreator.fromString(originToken!!)
return true
}
}

View File

@ -0,0 +1,48 @@
package kr.co.vividnext.sodalive.aws.cloudfront
import com.amazonaws.services.cloudfront.CloudFrontUrlSigner
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import java.nio.file.Files
import java.nio.file.Paths
import java.security.KeyFactory
import java.security.PrivateKey
import java.security.spec.PKCS8EncodedKeySpec
import java.util.Date
@Component
class AudioContentCloudFront(
@Value("\${cloud.aws.content-cloud-front.host}")
private val cloudfrontDomain: String,
@Value("\${cloud.aws.content-cloud-front.private-key-file-path}")
private val privateKeyFilePath: String,
@Value("\${cloud.aws.content-cloud-front.key-pair-id}")
private val keyPairId: String
) {
fun generateSignedURL(
resourcePath: String,
expirationTime: Long
): String {
// Load private key from file
val privateKey = loadPrivateKey(privateKeyFilePath)
// Generate signed URL for resource with custom policy and expiration time
return CloudFrontUrlSigner.getSignedURLWithCannedPolicy(
"$cloudfrontDomain/$resourcePath", // Resource URL
keyPairId, // CloudFront key pair ID
privateKey, // CloudFront private key
Date(System.currentTimeMillis() + expirationTime) // Expiration date
)
}
private fun loadPrivateKey(resourceName: String): PrivateKey {
val path = Paths.get(resourceName)
val bytes = Files.readAllBytes(path)
val keySpec = PKCS8EncodedKeySpec(bytes)
val keyFactory = KeyFactory.getInstance("RSA")
return keyFactory.generatePrivate(keySpec)
}
}

View File

@ -0,0 +1,34 @@
package kr.co.vividnext.sodalive.aws.s3
import com.amazonaws.services.s3.AmazonS3Client
import com.amazonaws.services.s3.model.ObjectMetadata
import com.amazonaws.services.s3.model.PutObjectRequest
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import java.io.InputStream
@Component
class S3Uploader(private val amazonS3Client: AmazonS3Client) {
private val logger = LoggerFactory.getLogger(this::class.java)
fun upload(
inputStream: InputStream,
bucket: String,
filePath: String,
metadata: ObjectMetadata? = null
): String {
putS3(inputStream, bucket, filePath, metadata)
return filePath
}
private fun putS3(
inputStream: InputStream,
bucket: String,
filePath: String,
metadata: ObjectMetadata?
): String {
amazonS3Client.putObject(PutObjectRequest(bucket, filePath, inputStream, metadata))
logger.info("파일이 업로드 되었습니다.")
return amazonS3Client.getUrl(bucket, filePath).toString()
}
}

View File

@ -0,0 +1,20 @@
package kr.co.vividnext.sodalive.can
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
@Entity
data class Can(
var title: String,
var can: Int,
var rewardCan: Int,
var price: Int,
@Enumerated(value = EnumType.STRING)
var status: CanStatus
) : BaseEntity()
enum class CanStatus {
SALE, END_OF_SALE
}

View File

@ -0,0 +1,60 @@
package kr.co.vividnext.sodalive.can
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import org.springframework.data.domain.Pageable
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/can")
class CanController(private val service: CanService) {
@GetMapping
fun getCans(): ApiResponse<List<CanResponse>> {
return ApiResponse.ok(service.getCans())
}
@GetMapping("/status")
fun getCanStatus(
@RequestParam container: String,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) {
throw SodaException("로그인 정보를 확인해주세요.")
}
ApiResponse.ok(service.getCanStatus(member, container))
}
@GetMapping("/status/use")
fun getCanUseStatus(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@RequestParam("timezone") timezone: String,
@RequestParam("container") container: String,
pageable: Pageable
) = run {
if (member == null) {
throw SodaException("로그인 정보를 확인해주세요.")
}
ApiResponse.ok(service.getCanUseStatus(member, pageable, timezone, container))
}
@GetMapping("/status/charge")
fun getCanChargeStatus(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@RequestParam("timezone") timezone: String,
@RequestParam("container") container: String,
pageable: Pageable
) = run {
if (member == null) {
throw SodaException("로그인 정보를 확인해주세요.")
}
ApiResponse.ok(service.getCanChargeStatus(member, pageable, timezone, container))
}
}

View File

@ -0,0 +1,130 @@
package kr.co.vividnext.sodalive.can
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.can.QCan.can1
import kr.co.vividnext.sodalive.can.charge.Charge
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
import kr.co.vividnext.sodalive.can.charge.QCharge.charge
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
import kr.co.vividnext.sodalive.can.payment.QPayment.payment
import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.can.use.QUseCan.useCan
import kr.co.vividnext.sodalive.can.use.UseCan
import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.QMember
import kr.co.vividnext.sodalive.member.QMember.member
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface CanRepository : JpaRepository<Can, Long>, CanQueryRepository
interface CanQueryRepository {
fun findAllByStatus(status: CanStatus): List<CanResponse>
fun getCanUseStatus(member: Member, pageable: Pageable): List<UseCan>
fun getCanChargeStatus(member: Member, pageable: Pageable, container: String): List<Charge>
fun isExistPaidLiveRoom(memberId: Long, roomId: Long): UseCan?
fun getCanUsedForLiveRoomNotRefund(memberId: Long, roomId: Long, canUsage: CanUsage = CanUsage.LIVE): UseCan?
}
@Repository
class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQueryRepository {
override fun findAllByStatus(status: CanStatus): List<CanResponse> {
return queryFactory
.select(
QCanResponse(
can1.id,
can1.title,
can1.can,
can1.rewardCan,
can1.price
)
)
.from(can1)
.where(can1.status.eq(status))
.orderBy(can1.can.asc())
.fetch()
}
override fun getCanUseStatus(member: Member, pageable: Pageable): List<UseCan> {
return queryFactory
.selectFrom(useCan)
.where(useCan.member.id.eq(member.id))
.offset(pageable.offset)
.limit(pageable.pageSize.toLong())
.orderBy(useCan.id.desc())
.fetch()
}
override fun getCanChargeStatus(member: Member, pageable: Pageable, container: String): List<Charge> {
val qMember = QMember.member
val chargeStatusCondition = when (container) {
"aos" -> {
charge.payment.paymentGateway.eq(PaymentGateway.PG)
.or(charge.payment.paymentGateway.eq(PaymentGateway.GOOGLE_IAP))
}
"ios" -> {
charge.payment.paymentGateway.eq(PaymentGateway.PG)
.or(charge.payment.paymentGateway.eq(PaymentGateway.APPLE_IAP))
}
else -> charge.payment.paymentGateway.eq(PaymentGateway.PG)
}
return queryFactory
.selectFrom(charge)
.innerJoin(charge.member, qMember)
.leftJoin(charge.useCan, useCan)
.leftJoin(charge.payment, payment)
.where(
qMember.id.eq(member.id)
.and(
payment.status.eq(PaymentStatus.COMPLETE)
.or(
charge.status.eq(ChargeStatus.REFUND_CHARGE)
.and(useCan.isNotNull)
)
.or(charge.status.eq(ChargeStatus.EVENT))
.or(charge.status.eq(ChargeStatus.ADMIN))
)
.and(chargeStatusCondition)
)
.offset(pageable.offset)
.limit(pageable.pageSize.toLong())
.orderBy(charge.id.desc())
.fetch()
}
override fun isExistPaidLiveRoom(memberId: Long, roomId: Long): UseCan? {
return queryFactory
.selectFrom(useCan)
.innerJoin(useCan.member, member)
.innerJoin(useCan.room, liveRoom)
.where(
member.id.eq(memberId)
.and(liveRoom.id.eq(roomId))
.and(useCan.canUsage.eq(CanUsage.LIVE))
)
.orderBy(useCan.id.desc())
.fetchFirst()
}
override fun getCanUsedForLiveRoomNotRefund(memberId: Long, roomId: Long, canUsage: CanUsage): UseCan? {
return queryFactory
.selectFrom(useCan)
.innerJoin(useCan.member, member)
.innerJoin(useCan.room, liveRoom)
.where(
member.id.eq(memberId)
.and(liveRoom.id.eq(roomId))
.and(useCan.canUsage.eq(canUsage))
.and(useCan.isRefund.isFalse)
)
.orderBy(useCan.id.desc())
.fetchFirst()
}
}

View File

@ -0,0 +1,11 @@
package kr.co.vividnext.sodalive.can
import com.querydsl.core.annotations.QueryProjection
data class CanResponse @QueryProjection constructor(
val id: Long,
val title: String,
val can: Int,
val rewardCan: Int,
val price: Int
)

View File

@ -0,0 +1,118 @@
package kr.co.vividnext.sodalive.can
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.member.Member
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Service
class CanService(private val repository: CanRepository) {
fun getCans(): List<CanResponse> {
return repository.findAllByStatus(status = CanStatus.SALE)
}
fun getCanStatus(member: Member, container: String): GetCanStatusResponse {
return GetCanStatusResponse(
chargeCan = member.getChargeCan(container),
rewardCan = member.getRewardCan(container)
)
}
fun getCanUseStatus(
member: Member,
pageable: Pageable,
timezone: String,
container: String
): List<GetCanUseStatusResponseItem> {
return repository.getCanUseStatus(member, pageable)
.filter { (it.can + it.rewardCan) > 0 }
.filter {
when (container) {
"aos" -> {
it.useCanCalculates.any { useCanCalculate ->
useCanCalculate.paymentGateway == PaymentGateway.PG ||
useCanCalculate.paymentGateway == PaymentGateway.GOOGLE_IAP
}
}
"ios" -> {
it.useCanCalculates.any { useCanCalculate ->
useCanCalculate.paymentGateway == PaymentGateway.PG ||
useCanCalculate.paymentGateway == PaymentGateway.APPLE_IAP
}
}
else -> it.useCanCalculates.any { useCanCalculate ->
useCanCalculate.paymentGateway == PaymentGateway.PG
}
}
}
.map {
val title: String = when (it.canUsage) {
CanUsage.DONATION -> {
"[후원] ${it.room!!.member!!.nickname}"
}
CanUsage.LIVE -> {
"[라이브] ${it.room!!.title}"
}
CanUsage.CHANGE_NICKNAME -> "닉네임 변경"
CanUsage.ORDER_CONTENT -> "콘텐츠 구매"
}
val createdAt = it.createdAt!!
.atZone(ZoneId.of("UTC"))
.withZoneSameInstant(ZoneId.of(timezone))
GetCanUseStatusResponseItem(
title = title,
date = createdAt.format(
DateTimeFormatter.ofPattern("yyyy-MM-dd | HH:mm:ss")
),
can = it.can + it.rewardCan
)
}
}
fun getCanChargeStatus(
member: Member,
pageable: Pageable,
timezone: String,
container: String
): List<GetCanChargeStatusResponseItem> {
return repository.getCanChargeStatus(member, pageable, container)
.map {
val canTitle = it.title ?: ""
val chargeMethod = when (it.status) {
ChargeStatus.CHARGE, ChargeStatus.EVENT -> {
it.payment!!.method ?: ""
}
ChargeStatus.REFUND_CHARGE -> {
"환불"
}
else -> {
"환불"
}
}
val createdAt = it.createdAt!!
.atZone(ZoneId.of("UTC"))
.withZoneSameInstant(ZoneId.of(timezone))
GetCanChargeStatusResponseItem(
canTitle = canTitle,
date = createdAt.format(
DateTimeFormatter.ofPattern("yyyy-MM-dd | HH:mm:ss")
),
chargeMethod = chargeMethod
)
}
}
}

View File

@ -0,0 +1,7 @@
package kr.co.vividnext.sodalive.can
data class GetCanChargeStatusResponseItem(
val canTitle: String,
val date: String,
val chargeMethod: String
)

View File

@ -0,0 +1,6 @@
package kr.co.vividnext.sodalive.can
data class GetCanStatusResponse(
val chargeCan: Int,
val rewardCan: Int
)

View File

@ -0,0 +1,7 @@
package kr.co.vividnext.sodalive.can
data class GetCanUseStatusResponseItem(
val title: String,
val date: String,
val can: Int
)

View File

@ -0,0 +1,45 @@
package kr.co.vividnext.sodalive.can.charge
import kr.co.vividnext.sodalive.can.Can
import kr.co.vividnext.sodalive.can.payment.Payment
import kr.co.vividnext.sodalive.can.use.UseCan
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.member.Member
import javax.persistence.CascadeType
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
import javax.persistence.OneToOne
@Entity
data class Charge(
var chargeCan: Int,
var rewardCan: Int,
@Enumerated(value = EnumType.STRING)
var status: ChargeStatus = ChargeStatus.CHARGE
) : BaseEntity() {
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "can_id", nullable = true)
var can: Can? = null
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
var member: Member? = null
@OneToOne(fetch = FetchType.LAZY, cascade = [CascadeType.ALL])
@JoinColumn(name = "payment_id", nullable = true)
var payment: Payment? = null
set(value) {
value?.charge = this
field = value
}
@OneToOne(fetch = FetchType.LAZY, cascade = [CascadeType.ALL])
@JoinColumn(name = "use_can_id", nullable = true)
var useCan: UseCan? = null
var title: String? = null
}

View File

@ -0,0 +1,52 @@
package kr.co.vividnext.sodalive.can.charge
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.core.userdetails.User
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/charge")
class ChargeController(private val service: ChargeService) {
@PostMapping
fun charge(
@RequestBody chargeRequest: ChargeRequest,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) {
throw SodaException("로그인 정보를 확인해주세요.")
}
ApiResponse.ok(service.charge(member, chargeRequest))
}
@PostMapping("/verify")
fun verify(
@RequestBody verifyRequest: VerifyRequest,
@AuthenticationPrincipal user: User
) = ApiResponse.ok(service.verify(user, verifyRequest))
@PostMapping("/apple")
fun appleCharge(
@RequestBody chargeRequest: AppleChargeRequest,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) {
throw SodaException("로그인 정보를 확인해주세요.")
}
ApiResponse.ok(service.appleCharge(member, chargeRequest))
}
@PostMapping("/apple/verify")
fun appleVerify(
@RequestBody verifyRequest: AppleVerifyRequest,
@AuthenticationPrincipal user: User
) = ApiResponse.ok(service.appleVerify(user, verifyRequest))
}

View File

@ -0,0 +1,35 @@
package kr.co.vividnext.sodalive.can.charge
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
data class ChargeRequest(val canId: Long, val paymentGateway: PaymentGateway)
data class ChargeResponse(val chargeId: Long)
data class VerifyRequest(
@JsonProperty("receipt_id")
val receiptId: String,
@JsonProperty("order_id")
val orderId: String
)
data class VerifyResult(
@JsonProperty("receipt_id")
val receiptId: String,
val method: String,
val status: Int,
val price: Int
)
data class AppleChargeRequest(
val title: String,
val chargeCan: Int,
val paymentGateway: PaymentGateway,
var price: Double? = null,
var locale: String? = null
)
data class AppleVerifyRequest(val receiptString: String, val chargeId: Long)
data class AppleVerifyResponse(val status: Int)

View File

@ -0,0 +1,78 @@
package kr.co.vividnext.sodalive.can.charge
import com.querydsl.core.types.dsl.BooleanExpression
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.can.charge.QCharge.charge
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
import kr.co.vividnext.sodalive.can.payment.QPayment.payment
import kr.co.vividnext.sodalive.member.QMember.member
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface ChargeRepository : JpaRepository<Charge, Long>, ChargeQueryRepository
interface ChargeQueryRepository {
fun getOldestChargeWhereRewardCanGreaterThan0(chargeId: Long, memberId: Long, container: String): Charge?
fun getOldestChargeWhereChargeCanGreaterThan0(chargeId: Long, memberId: Long, container: String): Charge?
}
class ChargeQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : ChargeQueryRepository {
override fun getOldestChargeWhereRewardCanGreaterThan0(
chargeId: Long,
memberId: Long,
container: String
): Charge? {
return queryFactory
.selectFrom(charge)
.innerJoin(charge.member, member)
.leftJoin(charge.payment, payment)
.where(
member.id.eq(memberId)
.and(charge.rewardCan.gt(0))
.and(charge.id.gt(chargeId))
.and(payment.status.eq(PaymentStatus.COMPLETE))
.and(getPaymentGatewayCondition(container))
)
.orderBy(charge.id.asc())
.fetchFirst()
}
override fun getOldestChargeWhereChargeCanGreaterThan0(
chargeId: Long,
memberId: Long,
container: String
): Charge? {
return queryFactory
.selectFrom(charge)
.innerJoin(charge.member, member)
.leftJoin(charge.payment, payment)
.where(
member.id.eq(memberId)
.and(charge.chargeCan.gt(0))
.and(charge.id.gt(chargeId))
.and(payment.status.eq(PaymentStatus.COMPLETE))
.and(getPaymentGatewayCondition(container))
)
.orderBy(charge.id.asc())
.fetchFirst()
}
private fun getPaymentGatewayCondition(container: String): BooleanExpression? {
val paymentGatewayCondition = when (container) {
"aos" -> {
payment.paymentGateway.eq(PaymentGateway.PG)
.or(payment.paymentGateway.eq(PaymentGateway.GOOGLE_IAP))
}
"ios" -> {
payment.paymentGateway.eq(PaymentGateway.PG)
.or(payment.paymentGateway.eq(PaymentGateway.APPLE_IAP))
}
else -> payment.paymentGateway.eq(PaymentGateway.PG)
}
return paymentGatewayCondition
}
}

View File

@ -0,0 +1,203 @@
package kr.co.vividnext.sodalive.can.charge
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.bootpay.Bootpay
import kr.co.vividnext.sodalive.can.CanRepository
import kr.co.vividnext.sodalive.can.payment.Payment
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpHeaders
import org.springframework.security.core.userdetails.User
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
@Transactional(readOnly = true)
class ChargeService(
private val chargeRepository: ChargeRepository,
private val canRepository: CanRepository,
private val memberRepository: MemberRepository,
private val objectMapper: ObjectMapper,
private val okHttpClient: OkHttpClient,
@Value("\${bootpay.application-id}")
private val bootpayApplicationId: String,
@Value("\${bootpay.private-key}")
private val bootpayPrivateKey: String,
@Value("\${apple.iap-verify-sandbox-url}")
private val appleInAppVerifySandBoxUrl: String,
@Value("\${apple.iap-verify-url}")
private val appleInAppVerifyUrl: String
) {
@Transactional
fun charge(member: Member, request: ChargeRequest): ChargeResponse {
val can = canRepository.findByIdOrNull(request.canId)
?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.")
val charge = Charge(can.can, can.rewardCan)
charge.title = can.title
charge.member = member
charge.can = can
val payment = Payment(paymentGateway = request.paymentGateway)
payment.price = can.price.toDouble()
charge.payment = payment
chargeRepository.save(charge)
return ChargeResponse(chargeId = charge.id!!)
}
@Transactional
fun verify(user: User, verifyRequest: VerifyRequest) {
val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong())
?: throw SodaException("결제정보에 오류가 있습니다.")
val member = memberRepository.findByEmail(user.username)
?: throw SodaException("로그인 정보를 확인해주세요.")
if (charge.payment!!.paymentGateway == PaymentGateway.PG) {
val bootpay = Bootpay(bootpayApplicationId, bootpayPrivateKey)
try {
bootpay.accessToken
val verifyResult = objectMapper.convertValue(
bootpay.getReceipt(verifyRequest.receiptId),
VerifyResult::class.java
)
if (verifyResult.status == 1 && verifyResult.price == charge.can?.price) {
charge.payment?.receiptId = verifyResult.receiptId
charge.payment?.method = verifyResult.method
charge.payment?.status = PaymentStatus.COMPLETE
member.charge(charge.chargeCan, charge.rewardCan, "pg")
} else {
throw SodaException("결제정보에 오류가 있습니다.")
}
} catch (e: Exception) {
throw SodaException("결제정보에 오류가 있습니다.")
}
} else {
throw SodaException("결제정보에 오류가 있습니다.")
}
}
@Transactional
fun appleCharge(member: Member, request: AppleChargeRequest): ChargeResponse {
val charge = Charge(request.chargeCan, 0)
charge.title = request.title
charge.member = member
val payment = Payment(paymentGateway = request.paymentGateway)
payment.price = if (request.price != null) {
request.price!!
} else {
0.toDouble()
}
payment.locale = request.locale
charge.payment = payment
chargeRepository.save(charge)
return ChargeResponse(chargeId = charge.id!!)
}
@Transactional
fun appleVerify(user: User, verifyRequest: AppleVerifyRequest) {
val charge = chargeRepository.findByIdOrNull(verifyRequest.chargeId)
?: throw SodaException("결제정보에 오류가 있습니다.")
val member = memberRepository.findByEmail(user.username)
?: throw SodaException("로그인 정보를 확인해주세요.")
if (charge.payment!!.paymentGateway == PaymentGateway.APPLE_IAP) {
// 검증로직
if (requestRealServerVerify(verifyRequest)) {
charge.payment?.receiptId = verifyRequest.receiptString
charge.payment?.method = "애플(인 앱 결제)"
charge.payment?.status = PaymentStatus.COMPLETE
member.charge(charge.chargeCan, charge.rewardCan, "ios")
} else {
throw SodaException("결제정보에 오류가 있습니다.")
}
} else {
throw SodaException("결제정보에 오류가 있습니다.")
}
}
private fun requestRealServerVerify(verifyRequest: AppleVerifyRequest): Boolean {
val body = JSONObject()
body.put("receipt-data", verifyRequest.receiptString)
val request = Request.Builder()
.url(appleInAppVerifyUrl)
.addHeader(HttpHeaders.CONTENT_TYPE, "application/json")
.post(body.toString().toRequestBody("application/json".toMediaTypeOrNull()))
.build()
val response = okHttpClient.newCall(request).execute()
if (response.isSuccessful) {
val responseString = response.body?.string()
if (responseString != null) {
val verifyResult = objectMapper.readValue(responseString, AppleVerifyResponse::class.java)
return when (verifyResult.status) {
0 -> {
true
}
21007 -> {
requestSandboxServerVerify(verifyRequest)
}
else -> {
throw SodaException("결제정보에 오류가 있습니다.")
}
}
} else {
throw SodaException("결제를 완료하지 못했습니다.")
}
} else {
throw SodaException("결제를 완료하지 못했습니다.")
}
}
private fun requestSandboxServerVerify(verifyRequest: AppleVerifyRequest): Boolean {
val body = JSONObject()
body.put("receipt-data", verifyRequest.receiptString)
val request = Request.Builder()
.url(appleInAppVerifySandBoxUrl)
.addHeader(HttpHeaders.CONTENT_TYPE, "application/json")
.post(body.toString().toRequestBody("application/json".toMediaTypeOrNull()))
.build()
val response = okHttpClient.newCall(request).execute()
if (response.isSuccessful) {
val responseString = response.body?.string()
if (responseString != null) {
val verifyResult = objectMapper.readValue(responseString, AppleVerifyResponse::class.java)
return when (verifyResult.status) {
0 -> {
true
}
else -> {
throw SodaException("결제정보에 오류가 있습니다.")
}
}
} else {
throw SodaException("결제를 완료하지 못했습니다.")
}
} else {
throw SodaException("결제를 완료하지 못했습니다.")
}
}
}

View File

@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.can.charge
enum class ChargeStatus {
CHARGE, REFUND_CHARGE, EVENT, CANCEL,
// 관리자 지급
ADMIN
}

View File

@ -0,0 +1,298 @@
package kr.co.vividnext.sodalive.can.payment
import kr.co.vividnext.sodalive.can.CanRepository
import kr.co.vividnext.sodalive.can.charge.Charge
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.can.use.SpentCan
import kr.co.vividnext.sodalive.can.use.TotalSpentCan
import kr.co.vividnext.sodalive.can.use.UseCan
import kr.co.vividnext.sodalive.can.use.UseCanCalculate
import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
import kr.co.vividnext.sodalive.can.use.UseCanRepository
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.order.Order
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.member.MemberRepository
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class CanPaymentService(
private val repository: CanRepository,
private val memberRepository: MemberRepository,
private val chargeRepository: ChargeRepository,
private val useCanRepository: UseCanRepository,
private val useCanCalculateRepository: UseCanCalculateRepository
) {
@Transactional
fun spendCan(
memberId: Long,
needCan: Int,
canUsage: CanUsage,
liveRoom: LiveRoom? = null,
order: Order? = null,
audioContent: AudioContent? = null,
container: String
) {
val member = memberRepository.findByIdOrNull(id = memberId)
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
val useRewardCan = spendRewardCan(memberId, needCan, container)
val useChargeCan = if (needCan - useRewardCan.total > 0) {
spendChargeCan(memberId, needCan = needCan - useRewardCan.total, container = container)
} else {
null
}
if (needCan - useRewardCan.total - (useChargeCan?.total ?: 0) > 0) {
throw SodaException(
"${needCan - useRewardCan.total - (useChargeCan?.total ?: 0)} " +
"캔이 부족합니다. 충전 후 이용해 주세요."
)
}
if (!useRewardCan.verify() || useChargeCan?.verify() == false) {
throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
}
val useCan = UseCan(
canUsage = canUsage,
can = useChargeCan?.total ?: 0,
rewardCan = useRewardCan.total
)
var recipientId: Long? = null
if (canUsage == CanUsage.LIVE && liveRoom != null) {
recipientId = liveRoom.member!!.id!!
useCan.room = liveRoom
useCan.member = member
} else if (canUsage == CanUsage.CHANGE_NICKNAME) {
useCan.member = member
} else if (canUsage == CanUsage.DONATION && liveRoom != null) {
recipientId = liveRoom.member!!.id!!
useCan.room = liveRoom
useCan.member = member
} else if (canUsage == CanUsage.ORDER_CONTENT && order != null) {
recipientId = order.creator!!.id!!
useCan.order = order
useCan.member = member
} else if (canUsage == CanUsage.DONATION && audioContent != null) {
recipientId = audioContent.member!!.id!!
useCan.audioContent = audioContent
useCan.member = member
} else {
throw SodaException("잘못된 요청입니다.")
}
useCanRepository.save(useCan)
setUseCanCalculate(recipientId, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG)
setUseCanCalculate(
recipientId,
useRewardCan,
useChargeCan,
useCan,
paymentGateway = PaymentGateway.GOOGLE_IAP
)
setUseCanCalculate(
recipientId,
useRewardCan,
useChargeCan,
useCan,
paymentGateway = PaymentGateway.APPLE_IAP
)
}
private fun setUseCanCalculate(
recipientId: Long?,
useRewardCan: TotalSpentCan,
useChargeCan: TotalSpentCan?,
useCan: UseCan,
paymentGateway: PaymentGateway
) {
val totalSpentRewardCan = useRewardCan.spentCans
.filter { it.paymentGateway == paymentGateway }
.fold(0) { sum, spentCans -> sum + spentCans.can }
val useCanCalculate = if (useChargeCan != null) {
val totalSpentChargeCan = useChargeCan.spentCans
.filter { it.paymentGateway == paymentGateway }
.fold(0) { sum, spentCans -> sum + spentCans.can }
UseCanCalculate(
can = totalSpentChargeCan + totalSpentRewardCan,
paymentGateway = paymentGateway,
status = UseCanCalculateStatus.RECEIVED
)
} else {
UseCanCalculate(
can = totalSpentRewardCan,
paymentGateway = paymentGateway,
status = UseCanCalculateStatus.RECEIVED
)
}
if (useCanCalculate.can > 0) {
useCanCalculate.useCan = useCan
useCanCalculate.recipientCreatorId = recipientId
useCanCalculateRepository.save(useCanCalculate)
}
}
private fun spendRewardCan(memberId: Long, needCan: Int, container: String): TotalSpentCan {
return if (needCan > 0) {
val member = memberRepository.findByIdOrNull(id = memberId)
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
val spentCans = mutableListOf<SpentCan>()
var chargeId = 0L
var total = 0
while (needCan - total > 0) {
val remainingNeedCan = needCan - total
val charge = chargeRepository.getOldestChargeWhereRewardCanGreaterThan0(chargeId, memberId, container)
?: break
if (charge.rewardCan >= remainingNeedCan) {
charge.rewardCan -= remainingNeedCan
when (charge.payment!!.paymentGateway) {
PaymentGateway.PG -> member.pgRewardCan -= remainingNeedCan
PaymentGateway.APPLE_IAP -> member.appleRewardCan -= remainingNeedCan
PaymentGateway.GOOGLE_IAP -> member.googleRewardCan -= remainingNeedCan
}
total += remainingNeedCan
spentCans.add(
SpentCan(
paymentGateway = charge.payment!!.paymentGateway,
can = remainingNeedCan
)
)
} else {
total += charge.rewardCan
spentCans.add(
SpentCan(
paymentGateway = charge.payment!!.paymentGateway,
can = charge.rewardCan
)
)
when (charge.payment!!.paymentGateway) {
PaymentGateway.PG -> member.pgRewardCan -= remainingNeedCan
PaymentGateway.APPLE_IAP -> member.appleRewardCan -= remainingNeedCan
PaymentGateway.GOOGLE_IAP -> member.googleRewardCan -= remainingNeedCan
}
charge.rewardCan = 0
}
chargeId = charge.id!!
}
TotalSpentCan(spentCans, total)
} else {
TotalSpentCan(total = 0)
}
}
private fun spendChargeCan(memberId: Long, needCan: Int, container: String): TotalSpentCan {
return if (needCan > 0) {
val member = memberRepository.findByIdOrNull(id = memberId)
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
val spentCans = mutableListOf<SpentCan>()
var chargeId = 0L
var total = 0
while (needCan - total > 0) {
val remainingNeedCan = needCan - total
val charge = chargeRepository.getOldestChargeWhereChargeCanGreaterThan0(chargeId, memberId, container)
?: break
if (charge.chargeCan >= remainingNeedCan) {
charge.chargeCan -= remainingNeedCan
when (charge.payment!!.paymentGateway) {
PaymentGateway.PG -> member.pgChargeCan -= remainingNeedCan
PaymentGateway.APPLE_IAP -> member.appleChargeCan -= remainingNeedCan
PaymentGateway.GOOGLE_IAP -> member.googleChargeCan -= remainingNeedCan
}
total += remainingNeedCan
spentCans.add(
SpentCan(
paymentGateway = charge.payment!!.paymentGateway,
can = remainingNeedCan
)
)
} else {
total += charge.chargeCan
spentCans.add(
SpentCan(
paymentGateway = charge.payment!!.paymentGateway,
can = charge.chargeCan
)
)
when (charge.payment!!.paymentGateway) {
PaymentGateway.PG -> member.pgChargeCan -= remainingNeedCan
PaymentGateway.APPLE_IAP -> member.appleChargeCan -= remainingNeedCan
PaymentGateway.GOOGLE_IAP -> member.pgChargeCan -= remainingNeedCan
}
charge.chargeCan = 0
}
chargeId = charge.id!!
}
TotalSpentCan(spentCans, total)
} else {
TotalSpentCan(total = 0)
}
}
@Transactional
fun refund(memberId: Long, roomId: Long) {
val member = memberRepository.findByIdOrNull(memberId)
?: throw SodaException("잘못된 예약정보 입니다.")
val useCan = repository.getCanUsedForLiveRoomNotRefund(
memberId = memberId,
roomId = roomId,
canUsage = CanUsage.LIVE
) ?: throw SodaException("잘못된 예약정보 입니다.")
useCan.isRefund = true
val useCanCalculates = useCanCalculateRepository.findByUseCanIdAndStatus(useCan.id!!)
useCanCalculates.forEach {
it.status = UseCanCalculateStatus.REFUND
val charge = Charge(0, it.can, status = ChargeStatus.REFUND_CHARGE)
charge.title = "${it.can}"
charge.useCan = useCan
when (it.paymentGateway) {
PaymentGateway.PG -> member.pgRewardCan += charge.rewardCan
PaymentGateway.GOOGLE_IAP -> member.googleRewardCan += charge.rewardCan
PaymentGateway.APPLE_IAP -> member.appleRewardCan += charge.rewardCan
}
charge.member = member
val payment = Payment(
status = PaymentStatus.COMPLETE,
paymentGateway = it.paymentGateway
)
payment.method = "환불"
charge.payment = payment
chargeRepository.save(charge)
}
}
}

View File

@ -0,0 +1,41 @@
package kr.co.vividnext.sodalive.can.payment
import kr.co.vividnext.sodalive.can.charge.Charge
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.FetchType
import javax.persistence.OneToOne
@Entity
data class Payment(
@Enumerated(value = EnumType.STRING)
var status: PaymentStatus = PaymentStatus.REQUEST,
@Column(nullable = false)
@Enumerated(value = EnumType.STRING)
val paymentGateway: PaymentGateway
) : BaseEntity() {
@OneToOne(mappedBy = "payment", fetch = FetchType.LAZY)
var charge: Charge? = null
@Column(columnDefinition = "TEXT", nullable = true)
var receiptId: String? = null
var method: String? = null
var price: Double = 0.toDouble()
var locale: String? = null
}
enum class PaymentStatus {
// 결제요청
REQUEST,
// 결제완료
COMPLETE,
// 환불
RETURN
}

View File

@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.can.payment
enum class PaymentGateway {
PG, GOOGLE_IAP, APPLE_IAP
}

Some files were not shown because too many files have changed in this diff Show More