diff --git a/.gitignore b/.gitignore index 5218d57..67ee418 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ HELP.md .gradle +.envrc build/ !**/src/main/**/build/ !**/src/test/**/build/ diff --git a/appspec.yml b/appspec.yml new file mode 100644 index 0000000..77ea8fe --- /dev/null +++ b/appspec.yml @@ -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 diff --git a/build.gradle.kts b/build.gradle.kts index 262c8b2..84666cf 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 { - kotlinOptions { - freeCompilerArgs += "-Xjsr305=strict" - jvmTarget = "11" - } + kotlinOptions { + freeCompilerArgs += "-Xjsr305=strict" + jvmTarget = "11" + } } tasks.withType { - useJUnitPlatform() + useJUnitPlatform() } tasks.getByName("jar") { diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 37aef8d..f00b59d 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/scripts/kill_process.sh b/scripts/kill_process.sh new file mode 100644 index 0000000..14ff76d --- /dev/null +++ b/scripts/kill_process.sh @@ -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 diff --git a/scripts/run_process.sh b/scripts/run_process.sh new file mode 100644 index 0000000..c0826ba --- /dev/null +++ b/scripts/run_process.sh @@ -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 & diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/SodaliveApplication.kt b/src/main/kotlin/kr/co/vividnext/sodalive/SodaLiveApplication.kt similarity index 58% rename from src/main/kotlin/kr/co/vividnext/sodalive/SodaliveApplication.kt rename to src/main/kotlin/kr/co/vividnext/sodalive/SodaLiveApplication.kt index 94e947f..bb4472f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/SodaliveApplication.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/SodaLiveApplication.kt @@ -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) { - runApplication(*args) + runApplication(*args) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/can/AdminCanChargeRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/can/AdminCanChargeRequest.kt new file mode 100644 index 0000000..bbb8fd0 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/can/AdminCanChargeRequest.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.admin.can + +data class AdminCanChargeRequest( + val memberId: Long, + val method: String, + val can: Int +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/can/AdminCanController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/can/AdminCanController.kt new file mode 100644 index 0000000..042eb29 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/can/AdminCanController.kt @@ -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)) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/can/AdminCanRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/can/AdminCanRepository.kt new file mode 100644 index 0000000..e784d13 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/can/AdminCanRepository.kt @@ -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 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/can/AdminCanRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/can/AdminCanRequest.kt new file mode 100644 index 0000000..92258ff --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/can/AdminCanRequest.kt @@ -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 + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/can/AdminCanService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/can/AdminCanService.kt new file mode 100644 index 0000000..786138b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/can/AdminCanService.kt @@ -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 + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/AdminChargeStatusController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/AdminChargeStatusController.kt new file mode 100644 index 0000000..be859be --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/AdminChargeStatusController.kt @@ -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)) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/AdminChargeStatusQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/AdminChargeStatusQueryRepository.kt new file mode 100644 index 0000000..cf2f704 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/AdminChargeStatusQueryRepository.kt @@ -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 { + 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 { + 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() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/AdminChargeStatusService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/AdminChargeStatusService.kt new file mode 100644 index 0000000..811e4bd --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/AdminChargeStatusService.kt @@ -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 { + 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 { + 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() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/GetChargeStatusDetailQueryDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/GetChargeStatusDetailQueryDto.kt new file mode 100644 index 0000000..fcfa0d9 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/GetChargeStatusDetailQueryDto.kt @@ -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 +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/GetChargeStatusDetailResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/GetChargeStatusDetailResponse.kt new file mode 100644 index 0000000..430b6f8 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/GetChargeStatusDetailResponse.kt @@ -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 +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/GetChargeStatusQueryDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/GetChargeStatusQueryDto.kt new file mode 100644 index 0000000..c5c16d0 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/GetChargeStatusQueryDto.kt @@ -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 +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/GetChargeStatusResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/GetChargeStatusResponse.kt new file mode 100644 index 0000000..efbe3aa --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/GetChargeStatusResponse.kt @@ -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 +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/AdminContentController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/AdminContentController.kt new file mode 100644 index 0000000..2b951e6 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/AdminContentController.kt @@ -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)) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/AdminContentRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/AdminContentRepository.kt new file mode 100644 index 0000000..1db3529 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/AdminContentRepository.kt @@ -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, AdminAudioContentQueryRepository + +interface AdminAudioContentQueryRepository { + fun getAudioContentTotalCount(searchWord: String = ""): Int + fun getAudioContentList(offset: Long, limit: Long, searchWord: String = ""): List + fun getHashTagList(audioContentId: Long): List +} + +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 { + 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 { + 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, + 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 + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/AdminContentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/AdminContentService.kt new file mode 100644 index 0000000..677e3b9 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/AdminContentService.kt @@ -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 + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/GetAdminContentListResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/GetAdminContentListResponse.kt new file mode 100644 index 0000000..81b88b8 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/GetAdminContentListResponse.kt @@ -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 +) + +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 = "" +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/UpdateAdminContentRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/UpdateAdminContentRequest.kt new file mode 100644 index 0000000..376485b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/UpdateAdminContentRequest.kt @@ -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? +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerController.kt new file mode 100644 index 0000000..1324585 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerController.kt @@ -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()) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerRepository.kt new file mode 100644 index 0000000..aa654dd --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerRepository.kt @@ -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, AdminContentBannerQueryRepository + +interface AdminContentBannerQueryRepository { + fun getAudioContentMainBannerList(): List +} + +class AdminContentBannerQueryRepositoryImpl( + private val queryFactory: JPAQueryFactory, + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) : AdminContentBannerQueryRepository { + override fun getAudioContentMainBannerList(): List { + 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() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerService.kt new file mode 100644 index 0000000..b7e4837 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerService.kt @@ -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) { + for (index in ids.indices) { + val tag = repository.findByIdOrNull(ids[index]) + + if (tag != null) { + tag.orders = index + 1 + } + } + } + + fun getAudioContentMainBannerList(): List { + return repository.getAudioContentMainBannerList() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/CreateContentBannerRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/CreateContentBannerRequest.kt new file mode 100644 index 0000000..3203366 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/CreateContentBannerRequest.kt @@ -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 +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/GetAdminContentBannerResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/GetAdminContentBannerResponse.kt new file mode 100644 index 0000000..6618f2b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/GetAdminContentBannerResponse.kt @@ -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 +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/UpdateBannerOrdersRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/UpdateBannerOrdersRequest.kt new file mode 100644 index 0000000..2dba5aa --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/UpdateBannerOrdersRequest.kt @@ -0,0 +1,5 @@ +package kr.co.vividnext.sodalive.admin.content.banner + +data class UpdateBannerOrdersRequest( + val ids: List +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/UpdateContentBannerRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/UpdateContentBannerRequest.kt new file mode 100644 index 0000000..fe97928 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/UpdateContentBannerRequest.kt @@ -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? +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/curation/AdminContentCurationController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/curation/AdminContentCurationController.kt new file mode 100644 index 0000000..849697a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/curation/AdminContentCurationController.kt @@ -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()) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/curation/AdminContentCurationRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/curation/AdminContentCurationRepository.kt new file mode 100644 index 0000000..a24fa77 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/curation/AdminContentCurationRepository.kt @@ -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, + AdminContentCurationQueryRepository + +interface AdminContentCurationQueryRepository { + fun getAudioContentCurationList(): List + fun findByIdAndActive(id: Long): AudioContentCuration? +} + +@Repository +class AdminContentCurationQueryRepositoryImpl( + private val queryFactory: JPAQueryFactory +) : AdminContentCurationQueryRepository { + override fun getAudioContentCurationList(): List { + 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() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/curation/AdminContentCurationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/curation/AdminContentCurationService.kt new file mode 100644 index 0000000..b6d0c14 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/curation/AdminContentCurationService.kt @@ -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) { + for (index in ids.indices) { + val audioContentCuration = repository.findByIdOrNull(ids[index]) + + if (audioContentCuration != null) { + audioContentCuration.orders = index + 1 + } + } + } + + fun getContentCurationList(): List { + return repository.getAudioContentCurationList() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/curation/AudioContentCurationRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/curation/AudioContentCurationRequest.kt new file mode 100644 index 0000000..49b46d7 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/curation/AudioContentCurationRequest.kt @@ -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 +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/curation/GetAdminContentCurationResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/curation/GetAdminContentCurationResponse.kt new file mode 100644 index 0000000..2938cfd --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/curation/GetAdminContentCurationResponse.kt @@ -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 +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/theme/AdminContentThemeController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/theme/AdminContentThemeController.kt new file mode 100644 index 0000000..354aabb --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/theme/AdminContentThemeController.kt @@ -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()) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/theme/AdminContentThemeRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/theme/AdminContentThemeRepository.kt new file mode 100644 index 0000000..77f2af2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/theme/AdminContentThemeRepository.kt @@ -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, AdminContentThemeQueryRepository + +interface AdminContentThemeQueryRepository { + fun findIdByTheme(theme: String): Long? + fun getActiveThemes(): List +} + +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 { + return queryFactory + .select( + QGetAudioContentThemeResponse( + audioContentTheme.id, + audioContentTheme.theme, + audioContentTheme.image.prepend("/").prepend(cloudFrontHost) + ) + ) + .from(audioContentTheme) + .where(audioContentTheme.isActive.isTrue) + .orderBy(audioContentTheme.orders.asc()) + .fetch() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/theme/AdminContentThemeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/theme/AdminContentThemeService.kt new file mode 100644 index 0000000..00e6237 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/theme/AdminContentThemeService.kt @@ -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) { + for (index in ids.indices) { + val theme = repository.findByIdOrNull(ids[index]) + + if (theme != null) { + theme.orders = index + 1 + } + } + } + + fun getThemes(): List { + return repository.getActiveThemes() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/theme/CreateContentThemeRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/theme/CreateContentThemeRequest.kt new file mode 100644 index 0000000..9df9b30 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/theme/CreateContentThemeRequest.kt @@ -0,0 +1,3 @@ +package kr.co.vividnext.sodalive.admin.content.theme + +data class CreateContentThemeRequest(val theme: String) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/theme/UpdateThemeOrdersRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/theme/UpdateThemeOrdersRequest.kt new file mode 100644 index 0000000..dacdc47 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/theme/UpdateThemeOrdersRequest.kt @@ -0,0 +1,5 @@ +package kr.co.vividnext.sodalive.admin.content.theme + +data class UpdateThemeOrdersRequest( + val ids: List +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/AdminChargeEventController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/AdminChargeEventController.kt new file mode 100644 index 0000000..0502923 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/AdminChargeEventController.kt @@ -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 { + 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()) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/AdminChargeEventRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/AdminChargeEventRepository.kt new file mode 100644 index 0000000..4cd02e4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/AdminChargeEventRepository.kt @@ -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, AdminChargeEventQueryRepository + +interface AdminChargeEventQueryRepository { + fun getChargeEventList(): List +} + +class AdminChargeEventQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AdminChargeEventQueryRepository { + override fun getChargeEventList(): List { + return queryFactory + .selectFrom(chargeEvent) + .orderBy(chargeEvent.createdAt.desc()) + .fetch() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/AdminChargeEventService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/AdminChargeEventService.kt new file mode 100644 index 0000000..fac0957 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/AdminChargeEventService.kt @@ -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 { + 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 + ) + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/ChargeEvent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/ChargeEvent.kt new file mode 100644 index 0000000..2d93162 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/ChargeEvent.kt @@ -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() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/CreateChargeEventRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/CreateChargeEventRequest.kt new file mode 100644 index 0000000..7e81f4e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/CreateChargeEventRequest.kt @@ -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 +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/GetChargeEventListResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/GetChargeEventListResponse.kt new file mode 100644 index 0000000..fbac78b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/GetChargeEventListResponse.kt @@ -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 +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/ModifyChargeEventRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/ModifyChargeEventRequest.kt new file mode 100644 index 0000000..1086ae2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/ModifyChargeEventRequest.kt @@ -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 +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/explorer/AdminExplorerController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/explorer/AdminExplorerController.kt new file mode 100644 index 0000000..59db936 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/explorer/AdminExplorerController.kt @@ -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 { + 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)) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/explorer/AdminExplorerSectionRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/explorer/AdminExplorerSectionRepository.kt new file mode 100644 index 0000000..347da74 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/explorer/AdminExplorerSectionRepository.kt @@ -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, AdminExplorerSectionQueryRepository + +interface AdminExplorerSectionQueryRepository { + fun findByTitle(title: String): ExplorerSection? + fun findAllWithPaging(offset: Long, limit: Long): List + 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 { + return queryFactory + .selectFrom(explorerSection) + .offset(offset) + .limit(limit) + .orderBy(explorerSection.orders.asc()) + .fetch() + } + + override fun totalCount(): Int { + return queryFactory + .selectFrom(explorerSection) + .fetch() + .size + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/explorer/AdminExplorerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/explorer/AdminExplorerService.kt new file mode 100644 index 0000000..7bab12d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/explorer/AdminExplorerService.kt @@ -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() + 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) { + 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 + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/explorer/CreateExplorerSectionRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/explorer/CreateExplorerSectionRequest.kt new file mode 100644 index 0000000..7e43118 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/explorer/CreateExplorerSectionRequest.kt @@ -0,0 +1,9 @@ +package kr.co.vividnext.sodalive.admin.explorer + +data class CreateExplorerSectionRequest( + val title: String, + val isAdult: Boolean, + val tagList: List, + val coloredTitle: String? = null, + val color: String? = null +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/explorer/GetAdminExplorerSectionResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/explorer/GetAdminExplorerSectionResponse.kt new file mode 100644 index 0000000..049be02 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/explorer/GetAdminExplorerSectionResponse.kt @@ -0,0 +1,16 @@ +package kr.co.vividnext.sodalive.admin.explorer + +data class GetAdminExplorerSectionResponse( + val totalCount: Int, + val explorerSectionItemList: List +) + +data class GetAdminExplorerSectionResponseItem( + val id: Long, + val title: String, + val coloredTitle: String, + val color: String, + val isAdult: Boolean, + val isActive: Boolean, + val tags: List +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/explorer/UpdateExplorerSectionOrdersRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/explorer/UpdateExplorerSectionOrdersRequest.kt new file mode 100644 index 0000000..ff3f2c2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/explorer/UpdateExplorerSectionOrdersRequest.kt @@ -0,0 +1,6 @@ +package kr.co.vividnext.sodalive.admin.explorer + +data class UpdateExplorerSectionOrdersRequest( + val firstOrders: Int, + val ids: List +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/explorer/UpdateExplorerSectionRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/explorer/UpdateExplorerSectionRequest.kt new file mode 100644 index 0000000..ee3d38f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/explorer/UpdateExplorerSectionRequest.kt @@ -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? = null, + val coloredTitle: String? = null, + val color: String? = null, + val isActive: Boolean? = null +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveController.kt new file mode 100644 index 0000000..869dd19 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveController.kt @@ -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), + "수정되었습니다." + ) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveRoomQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveRoomQueryRepository.kt new file mode 100644 index 0000000..dbc2890 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveRoomQueryRepository.kt @@ -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 { + 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 { + return queryFactory + .selectFrom(recommendLiveCreatorBanner) + .offset(pageable.offset) + .limit(pageable.pageSize.toLong()) + .orderBy(recommendLiveCreatorBanner.orders.asc(), recommendLiveCreatorBanner.id.desc()) + .fetch() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveService.kt new file mode 100644 index 0000000..0865230 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveService.kt @@ -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) { + for (index in ids.indices) { + val recommendCreatorBanner = recommendCreatorBannerRepository.findByIdOrNull(id = ids[index]) + + if (recommendCreatorBanner != null) { + recommendCreatorBanner.orders = firstOrders + index + } + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/GetAdminRecommendCreatorResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/GetAdminRecommendCreatorResponse.kt new file mode 100644 index 0000000..f527cfe --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/GetAdminRecommendCreatorResponse.kt @@ -0,0 +1,16 @@ +package kr.co.vividnext.sodalive.admin.live + +data class GetAdminRecommendCreatorResponse( + val totalCount: Int, + val recommendCreatorList: List +) + +data class GetAdminRecommendCreatorResponseItem( + val id: Long, + val image: String, + val creatorId: Long, + val creatorNickname: String, + val startDate: String, + val endDate: String, + val isAdult: Boolean +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/GetLiveResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/GetLiveResponse.kt new file mode 100644 index 0000000..b0f76fc --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/GetLiveResponse.kt @@ -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 +) + +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 +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/UpdateAdminRecommendCreatorBannerOrdersRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/UpdateAdminRecommendCreatorBannerOrdersRequest.kt new file mode 100644 index 0000000..93bd453 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/UpdateAdminRecommendCreatorBannerOrdersRequest.kt @@ -0,0 +1,6 @@ +package kr.co.vividnext.sodalive.admin.live + +data class UpdateAdminRecommendCreatorBannerOrdersRequest( + val firstOrders: Int, + val ids: List +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberController.kt new file mode 100644 index 0000000..436e23f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberController.kt @@ -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)) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberRepository.kt new file mode 100644 index 0000000..4b1a2ed --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberRepository.kt @@ -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, AdminMemberQueryRepository + +interface AdminMemberQueryRepository { + fun getMemberTotalCount(role: MemberRole? = null): Int + fun getMemberList(offset: Long, limit: Long, role: MemberRole? = null): List + fun searchMember(searchWord: String, offset: Long, limit: Long, role: MemberRole? = null): List + + fun searchMemberTotalCount(searchWord: String, role: MemberRole? = null): Int + fun getCreatorAllList(): List +} + +class AdminMemberQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AdminMemberQueryRepository { + override fun getMemberList(offset: Long, limit: Long, role: MemberRole?): List { + 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 { + 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 { + return queryFactory + .select( + QGetAdminCreatorAllListResponse( + member.id, + member.nickname + ) + ) + .from(member) + .where( + member.role.eq(MemberRole.CREATOR) + .and(member.isActive.isTrue) + ) + .fetch() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberService.kt new file mode 100644 index 0000000..8b81fbc --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberService.kt @@ -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 + ): List { + 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 { + return repository.getCreatorAllList() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/GetAdminCreatorAllListResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/GetAdminCreatorAllListResponse.kt new file mode 100644 index 0000000..30c006a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/GetAdminCreatorAllListResponse.kt @@ -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 +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/GetAdminMemberListResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/GetAdminMemberListResponse.kt new file mode 100644 index 0000000..1fd1c47 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/GetAdminMemberListResponse.kt @@ -0,0 +1,19 @@ +package kr.co.vividnext.sodalive.admin.member + +data class GetAdminMemberListResponse( + val totalCount: Int, + val items: List +) + +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 +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/UpdateMemberRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/UpdateMemberRequest.kt new file mode 100644 index 0000000..687ca6a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/UpdateMemberRequest.kt @@ -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 +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/tag/AdminMemberTagController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/tag/AdminMemberTagController.kt new file mode 100644 index 0000000..befa1e4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/tag/AdminMemberTagController.kt @@ -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), "수정되었습니다.") +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/tag/AdminMemberTagRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/tag/AdminMemberTagRepository.kt new file mode 100644 index 0000000..76061c7 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/tag/AdminMemberTagRepository.kt @@ -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, 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() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/tag/AdminMemberTagService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/tag/AdminMemberTagService.kt new file mode 100644 index 0000000..0e60a59 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/tag/AdminMemberTagService.kt @@ -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) { + for (index in ids.indices) { + val tag = repository.findByIdOrNull(ids[index]) + + if (tag != null) { + tag.orders = index + 1 + } + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/tag/CreateMemberTagRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/tag/CreateMemberTagRequest.kt new file mode 100644 index 0000000..fa70355 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/tag/CreateMemberTagRequest.kt @@ -0,0 +1,3 @@ +package kr.co.vividnext.sodalive.admin.member.tag + +data class CreateMemberTagRequest(val tag: String) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/tag/UpdateTagOrdersRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/tag/UpdateTagOrdersRequest.kt new file mode 100644 index 0000000..4da263a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/tag/UpdateTagOrdersRequest.kt @@ -0,0 +1,3 @@ +package kr.co.vividnext.sodalive.admin.member.tag + +data class UpdateTagOrdersRequest(val ids: List) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/agora/AccessToken.kt b/src/main/kotlin/kr/co/vividnext/sodalive/agora/AccessToken.kt new file mode 100644 index 0000000..ebc5648 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/agora/AccessToken.kt @@ -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 + + 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" + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/agora/AgoraUtils.kt b/src/main/kotlin/kr/co/vividnext/sodalive/agora/AgoraUtils.kt new file mode 100644 index 0000000..2af6158 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/agora/AgoraUtils.kt @@ -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()) + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/agora/ByteBuf.kt b/src/main/kotlin/kr/co/vividnext/sodalive/agora/ByteBuf.kt new file mode 100644 index 0000000..9254628 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/agora/ByteBuf.kt @@ -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): ByteBuf { + put(extra.size.toShort()) + for ((key, value) in extra.entries) { + put(key) + put(value) + } + return this + } + + fun putIntMap(extra: TreeMap): 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 { + val map = TreeMap() + val length = readShort() + + for (i in 0 until length) { + val k = readShort() + val v = readString() + map[k] = v + } + + return map + } + + fun readIntMap(): TreeMap { + val map = TreeMap() + val length = readShort() + + for (i in 0 until length) { + val k = readShort() + val v = readInt() + map[k] = v + } + + return map + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/agora/DynamicKey5.kt b/src/main/kotlin/kr/co/vividnext/sodalive/agora/DynamicKey5.kt new file mode 100644 index 0000000..9c35d0a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/agora/DynamicKey5.kt @@ -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 + ): 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, + 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() + 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 + ) : 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? = null + + constructor( + serviceType: Short, + signature: String?, + appID: ByteArray, + unixTs: Int, + salt: Int, + expiredTs: Int, + extra: TreeMap + ) : 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() + } + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/agora/DynamicKeyUtil.kt b/src/main/kotlin/kr/co/vividnext/sodalive/agora/DynamicKeyUtil.kt new file mode 100644 index 0000000..e98118a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/agora/DynamicKeyUtil.kt @@ -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() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/agora/Packable.kt b/src/main/kotlin/kr/co/vividnext/sodalive/agora/Packable.kt new file mode 100644 index 0000000..4447e33 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/agora/Packable.kt @@ -0,0 +1,5 @@ +package kr.co.vividnext.sodalive.agora + +interface Packable { + fun marshal(out: ByteBuf): ByteBuf +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/agora/PackableEx.kt b/src/main/kotlin/kr/co/vividnext/sodalive/agora/PackableEx.kt new file mode 100644 index 0000000..e84605e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/agora/PackableEx.kt @@ -0,0 +1,5 @@ +package kr.co.vividnext.sodalive.agora + +interface PackableEx : Packable { + fun unmarshal(input: ByteBuf) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/agora/RtcTokenBuilder.kt b/src/main/kotlin/kr/co/vividnext/sodalive/agora/RtcTokenBuilder.kt new file mode 100644 index 0000000..c5b1677 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/agora/RtcTokenBuilder.kt @@ -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() + "" + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/agora/RtmTokenBuilder.kt b/src/main/kotlin/kr/co/vividnext/sodalive/agora/RtmTokenBuilder.kt new file mode 100644 index 0000000..d00999f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/agora/RtmTokenBuilder.kt @@ -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 + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/aws/cloudfront/AudioContentCloudFront.kt b/src/main/kotlin/kr/co/vividnext/sodalive/aws/cloudfront/AudioContentCloudFront.kt new file mode 100644 index 0000000..0f50e56 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/aws/cloudfront/AudioContentCloudFront.kt @@ -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) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/aws/s3/S3Uploader.kt b/src/main/kotlin/kr/co/vividnext/sodalive/aws/s3/S3Uploader.kt new file mode 100644 index 0000000..bd3486d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/aws/s3/S3Uploader.kt @@ -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() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/Can.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/Can.kt new file mode 100644 index 0000000..1f0f4f6 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/Can.kt @@ -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 +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanController.kt new file mode 100644 index 0000000..af53b78 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanController.kt @@ -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> { + 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)) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt new file mode 100644 index 0000000..fd4bc7d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt @@ -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, CanQueryRepository + +interface CanQueryRepository { + fun findAllByStatus(status: CanStatus): List + fun getCanUseStatus(member: Member, pageable: Pageable): List + fun getCanChargeStatus(member: Member, pageable: Pageable, container: String): List + 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 { + 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 { + 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 { + 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() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanResponse.kt new file mode 100644 index 0000000..e3c5d48 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanResponse.kt @@ -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 +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt new file mode 100644 index 0000000..a8d99c8 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt @@ -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 { + 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 { + 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 { + 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 + ) + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/GetCanChargeStatusResponseItem.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/GetCanChargeStatusResponseItem.kt new file mode 100644 index 0000000..5564e01 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/GetCanChargeStatusResponseItem.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.can + +data class GetCanChargeStatusResponseItem( + val canTitle: String, + val date: String, + val chargeMethod: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/GetCanStatusResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/GetCanStatusResponse.kt new file mode 100644 index 0000000..401a551 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/GetCanStatusResponse.kt @@ -0,0 +1,6 @@ +package kr.co.vividnext.sodalive.can + +data class GetCanStatusResponse( + val chargeCan: Int, + val rewardCan: Int +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/GetCanUseStatusResponseItem.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/GetCanUseStatusResponseItem.kt new file mode 100644 index 0000000..bef3654 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/GetCanUseStatusResponseItem.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.can + +data class GetCanUseStatusResponseItem( + val title: String, + val date: String, + val can: Int +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/Charge.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/Charge.kt new file mode 100644 index 0000000..bb59e85 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/Charge.kt @@ -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 +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeController.kt new file mode 100644 index 0000000..6f6291b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeController.kt @@ -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)) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeData.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeData.kt new file mode 100644 index 0000000..7e7374a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeData.kt @@ -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) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeRepository.kt new file mode 100644 index 0000000..d8d5bf4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeRepository.kt @@ -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, 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 + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt new file mode 100644 index 0000000..a9c7e21 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt @@ -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("결제를 완료하지 못했습니다.") + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeStatus.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeStatus.kt new file mode 100644 index 0000000..07d5c0f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeStatus.kt @@ -0,0 +1,8 @@ +package kr.co.vividnext.sodalive.can.charge + +enum class ChargeStatus { + CHARGE, REFUND_CHARGE, EVENT, CANCEL, + + // 관리자 지급 + ADMIN +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt new file mode 100644 index 0000000..fd3a937 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt @@ -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() + 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() + 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) + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/Payment.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/Payment.kt new file mode 100644 index 0000000..7438bbd --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/Payment.kt @@ -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 +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/PaymentGateway.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/PaymentGateway.kt new file mode 100644 index 0000000..8874c09 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/PaymentGateway.kt @@ -0,0 +1,5 @@ +package kr.co.vividnext.sodalive.can.payment + +enum class PaymentGateway { + PG, GOOGLE_IAP, APPLE_IAP +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt new file mode 100644 index 0000000..5e53fdc --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt @@ -0,0 +1,8 @@ +package kr.co.vividnext.sodalive.can.use + +enum class CanUsage { + LIVE, + DONATION, + CHANGE_NICKNAME, + ORDER_CONTENT +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/TotalSpentCan.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/TotalSpentCan.kt new file mode 100644 index 0000000..7b3f283 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/TotalSpentCan.kt @@ -0,0 +1,18 @@ +package kr.co.vividnext.sodalive.can.use + +import kr.co.vividnext.sodalive.can.payment.PaymentGateway + +data class TotalSpentCan( + val spentCans: MutableList = mutableListOf(), + val total: Int +) { + fun verify(): Boolean { + val sumSpentCans = spentCans.fold(0) { sum, spentCan -> sum + spentCan.can } + return total == sumSpentCans + } +} + +data class SpentCan( + val paymentGateway: PaymentGateway, + val can: Int +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCan.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCan.kt new file mode 100644 index 0000000..683492a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCan.kt @@ -0,0 +1,51 @@ +package kr.co.vividnext.sodalive.can.use + +import kr.co.vividnext.sodalive.common.BaseEntity +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.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.OneToMany +import javax.persistence.OneToOne + +@Entity +data class UseCan( + @Enumerated(value = EnumType.STRING) + val canUsage: CanUsage, + + val can: Int, + + val rewardCan: Int, + + var isRefund: Boolean = false +) : BaseEntity() { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + var member: Member? = null + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "room_id", nullable = true) + var room: LiveRoom? = null + set(value) { + value?.useCan?.add(this) + field = value + } + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "order_id", nullable = true) + var order: Order? = null + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id", nullable = true) + var audioContent: AudioContent? = null + + @OneToMany(mappedBy = "useCan", cascade = [CascadeType.ALL]) + val useCanCalculates: MutableList = mutableListOf() +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCanCalculate.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCanCalculate.kt new file mode 100644 index 0000000..3b0efbe --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCanCalculate.kt @@ -0,0 +1,39 @@ +package kr.co.vividnext.sodalive.can.use + +import kr.co.vividnext.sodalive.can.payment.PaymentGateway +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.CascadeType +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne + +@Entity +data class UseCanCalculate( + val can: Int, + + @Column(nullable = false) + @Enumerated(value = EnumType.STRING) + val paymentGateway: PaymentGateway, + + @Column(nullable = false) + @Enumerated(value = EnumType.STRING) + var status: UseCanCalculateStatus +) : BaseEntity() { + @ManyToOne(fetch = FetchType.LAZY, cascade = [CascadeType.ALL]) + @JoinColumn(name = "use_can_id", nullable = false) + var useCan: UseCan? = null + set(value) { + value?.useCanCalculates?.add(this) + field = value + } + + var recipientCreatorId: Long? = null +} + +enum class UseCanCalculateStatus { + RECEIVED, CALCULATE_COMPLETE, REFUND +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCanCalculateRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCanCalculateRepository.kt new file mode 100644 index 0000000..3d47f56 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCanCalculateRepository.kt @@ -0,0 +1,12 @@ +package kr.co.vividnext.sodalive.can.use + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface UseCanCalculateRepository : JpaRepository { + fun findByUseCanIdAndStatus( + useCanId: Long, + status: UseCanCalculateStatus = UseCanCalculateStatus.RECEIVED + ): List +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCanRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCanRepository.kt new file mode 100644 index 0000000..f30429a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCanRepository.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.can.use + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface UseCanRepository : JpaRepository diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/common/ApiResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/common/ApiResponse.kt new file mode 100644 index 0000000..daf3890 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/common/ApiResponse.kt @@ -0,0 +1,22 @@ +package kr.co.vividnext.sodalive.common + +data class ApiResponse( + val success: Boolean, + val message: String? = null, + val data: T? = null, + val errorProperty: String? = null +) { + companion object { + fun ok(data: T? = null, message: String? = null) = ApiResponse( + success = true, + message = message, + data = data + ) + + fun error(message: String? = null, errorProperty: String? = null) = ApiResponse( + success = false, + message = message, + errorProperty = errorProperty + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/common/BaseEntity.kt b/src/main/kotlin/kr/co/vividnext/sodalive/common/BaseEntity.kt new file mode 100644 index 0000000..bf2b11d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/common/BaseEntity.kt @@ -0,0 +1,30 @@ +package kr.co.vividnext.sodalive.common + +import java.time.LocalDateTime +import javax.persistence.GeneratedValue +import javax.persistence.GenerationType +import javax.persistence.Id +import javax.persistence.MappedSuperclass +import javax.persistence.PrePersist +import javax.persistence.PreUpdate + +@MappedSuperclass +abstract class BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null + + var createdAt: LocalDateTime? = null + var updatedAt: LocalDateTime? = null + + @PrePersist + fun prePersist() { + createdAt = LocalDateTime.now() + updatedAt = LocalDateTime.now() + } + + @PreUpdate + fun preUpdate() { + updatedAt = LocalDateTime.now() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/common/ExceptionHandlerFilter.kt b/src/main/kotlin/kr/co/vividnext/sodalive/common/ExceptionHandlerFilter.kt new file mode 100644 index 0000000..a8ab7ee --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/common/ExceptionHandlerFilter.kt @@ -0,0 +1,26 @@ +package kr.co.vividnext.sodalive.common + +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.web.filter.OncePerRequestFilter +import javax.servlet.FilterChain +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +class ExceptionHandlerFilter(private val objectMapper: ObjectMapper) : OncePerRequestFilter() { + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + try { + filterChain.doFilter(request, response) + } catch (e: Exception) { + response.status = 401 + response.contentType = "application/json" + response.characterEncoding = "UTF-8" + + val json = objectMapper.writeValueAsString(ApiResponse.error("로그인 정보를 확인해주세요.")) + response.writer.write(json) + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaException.kt b/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaException.kt new file mode 100644 index 0000000..cf3cb9e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaException.kt @@ -0,0 +1,3 @@ +package kr.co.vividnext.sodalive.common + +class SodaException(message: String, val errorProperty: String? = null) : RuntimeException(message) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaExceptionHandler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaExceptionHandler.kt new file mode 100644 index 0000000..4b9aa7f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaExceptionHandler.kt @@ -0,0 +1,60 @@ +package kr.co.vividnext.sodalive.common + +import org.slf4j.LoggerFactory +import org.springframework.dao.DataIntegrityViolationException +import org.springframework.security.access.AccessDeniedException +import org.springframework.security.authentication.BadCredentialsException +import org.springframework.security.authentication.InternalAuthenticationServiceException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.multipart.MaxUploadSizeExceededException + +@RestControllerAdvice +class SodaExceptionHandler { + private val logger = LoggerFactory.getLogger(this::class.java) + + @ExceptionHandler(SodaException::class) + fun handleSudaException(e: SodaException) = run { + logger.error("API error", e) + ApiResponse.error( + message = e.message, + errorProperty = e.errorProperty + ) + } + + @ExceptionHandler(MaxUploadSizeExceededException::class) + fun handleMaxUploadSizeExceededException(e: MaxUploadSizeExceededException) = run { + logger.error("API error", e) + ApiResponse.error(message = "파일용량은 최대 1024MB까지 저장할 수 있습니다.") + } + + @ExceptionHandler(AccessDeniedException::class) + fun handleAccessDeniedException(e: AccessDeniedException) = run { + logger.error("API error", e) + ApiResponse.error(message = "권한이 없습니다.") + } + + @ExceptionHandler(InternalAuthenticationServiceException::class) + fun handleInternalAuthenticationServiceException(e: InternalAuthenticationServiceException) = run { + logger.error("API error", e) + ApiResponse.error("로그인 정보를 확인해주세요.") + } + + @ExceptionHandler(BadCredentialsException::class) + fun handleBadCredentialsException(e: BadCredentialsException) = run { + logger.error("API error", e) + ApiResponse.error("로그인 정보를 확인해주세요.") + } + + @ExceptionHandler(DataIntegrityViolationException::class) + fun handleDataIntegrityViolationException(e: DataIntegrityViolationException) = run { + logger.error("API error", e) + ApiResponse.error("이미 등록되어 있습니다.") + } + + @ExceptionHandler(Exception::class) + fun handleException(e: Exception) = run { + logger.error("API error", e) + ApiResponse.error("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.") + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/configs/AmazonS3Config.kt b/src/main/kotlin/kr/co/vividnext/sodalive/configs/AmazonS3Config.kt new file mode 100644 index 0000000..2e8f95d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/AmazonS3Config.kt @@ -0,0 +1,29 @@ +package kr.co.vividnext.sodalive.configs + +import com.amazonaws.auth.AWSStaticCredentialsProvider +import com.amazonaws.auth.BasicAWSCredentials +import com.amazonaws.services.s3.AmazonS3Client +import com.amazonaws.services.s3.AmazonS3ClientBuilder +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class AmazonS3Config( + @Value("\${cloud.aws.credentials.access-key}") + private val accessKey: String, + @Value("\${cloud.aws.credentials.secret-key}") + private val secretKey: String, + @Value("\${cloud.aws.region.static}") + private val region: String +) { + + @Bean + fun amazonS3Client(): AmazonS3Client { + val awsCredentials = BasicAWSCredentials(accessKey, secretKey) + return AmazonS3ClientBuilder.standard() + .withRegion(region) + .withCredentials(AWSStaticCredentialsProvider(awsCredentials)) + .build() as AmazonS3Client + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/configs/FirebaseConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/configs/FirebaseConfig.kt new file mode 100644 index 0000000..abf3132 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/FirebaseConfig.kt @@ -0,0 +1,25 @@ +package kr.co.vividnext.sodalive.configs + +import com.google.auth.oauth2.GoogleCredentials +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Configuration +import java.io.FileInputStream +import javax.annotation.PostConstruct + +@Configuration +class FirebaseConfig( + @Value("\${firebase.secret-key-path}") + private val secretKeyPath: String +) { + + @PostConstruct + fun initialize() { + FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(FileInputStream(secretKeyPath))) + .build() + + FirebaseApp.initializeApp() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/configs/OkHttpConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/configs/OkHttpConfig.kt new file mode 100644 index 0000000..6a5523a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/OkHttpConfig.kt @@ -0,0 +1,22 @@ +package kr.co.vividnext.sodalive.configs + +import okhttp3.OkHttpClient +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import java.util.concurrent.TimeUnit + +@Configuration +class OkHttpConfig { + @Bean("okHttpClient") + fun okHttpClient(): OkHttpClient { + return OkHttpClient() + .newBuilder().apply { + // 서버 연결을 최대 60초 수행 + connectTimeout(60, TimeUnit.SECONDS) + // 서버 요청을 최대 60초 수행 + writeTimeout(60, TimeUnit.SECONDS) + // 서버 응답을 최대 60초 기다림 + readTimeout(60, TimeUnit.SECONDS) + }.build() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/configs/QueryDslConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/configs/QueryDslConfig.kt new file mode 100644 index 0000000..dde8c84 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/QueryDslConfig.kt @@ -0,0 +1,18 @@ +package kr.co.vividnext.sodalive.configs + +import com.querydsl.jpa.impl.JPAQueryFactory +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import javax.persistence.EntityManager +import javax.persistence.PersistenceContext + +@Configuration +class QueryDslConfig( + @PersistenceContext + private val entityManager: EntityManager +) { + @Bean + fun jpaQueryFactory(): JPAQueryFactory { + return JPAQueryFactory(entityManager) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/configs/RedisConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/configs/RedisConfig.kt new file mode 100644 index 0000000..22480ee --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/RedisConfig.kt @@ -0,0 +1,30 @@ +package kr.co.vividnext.sodalive.configs + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.data.redis.connection.RedisConnectionFactory +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories + +@Configuration +@EnableRedisRepositories +class RedisConfig( + @Value("\${spring.redis.host}") + private val host: String, + @Value("\${spring.redis.port}") + private val port: Int +) { + @Bean + fun redisConnectionFactory(): RedisConnectionFactory { + return LettuceConnectionFactory(host, port) + } + + @Bean + fun redisTemplate(): RedisTemplate<*, *> { + val redisTemplate: RedisTemplate<*, *> = RedisTemplate() + redisTemplate.setConnectionFactory(redisConnectionFactory()) + return redisTemplate + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt new file mode 100644 index 0000000..986869f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt @@ -0,0 +1,78 @@ +package kr.co.vividnext.sodalive.configs + +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.common.ExceptionHandlerFilter +import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler +import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint +import kr.co.vividnext.sodalive.jwt.JwtFilter +import kr.co.vividnext.sodalive.jwt.TokenProvider +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.builders.WebSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter + +@Configuration +@EnableWebSecurity +@EnableGlobalMethodSecurity(prePostEnabled = true) +class SecurityConfig( + private val objectMapper: ObjectMapper, + private val tokenProvider: TokenProvider, + private val accessDeniedHandler: JwtAccessDeniedHandler, + private val authenticationEntryPoint: JwtAuthenticationEntryPoint +) { + @Bean + fun passwordEncoder(): PasswordEncoder { + return BCryptPasswordEncoder() + } + + @Bean + fun webSecurityCustomizer(): WebSecurityCustomizer { + return WebSecurityCustomizer { web: WebSecurity -> + web + .ignoring() + .antMatchers("/h2-console/**", "/favicon.ico", "/error") + } + } + + @Bean + fun filterChain(http: HttpSecurity): SecurityFilterChain { + val jwtFilter = JwtFilter(tokenProvider) + + return http + .cors() + .and() + .csrf().disable() + .exceptionHandling() + .authenticationEntryPoint(authenticationEntryPoint) + .accessDeniedHandler(accessDeniedHandler) + .and() + .headers() + .frameOptions() + .sameOrigin() + .and() + .sessionManagement() + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + .and() + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter::class.java) + .addFilterBefore(ExceptionHandlerFilter(objectMapper), JwtFilter::class.java) + .authorizeRequests() + .antMatchers("/member/check/email").permitAll() + .antMatchers("/member/check/nickname").permitAll() + .antMatchers("/member/signup").permitAll() + .antMatchers("/member/login").permitAll() + .antMatchers("/member/forgot-password").permitAll() + .antMatchers("/stplat/terms_of_service").permitAll() + .antMatchers("/stplat/privacy_policy").permitAll() + .anyRequest().authenticated() + .and() + .build() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/configs/WebConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/configs/WebConfig.kt new file mode 100644 index 0000000..ffbb936 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/WebConfig.kt @@ -0,0 +1,19 @@ +package kr.co.vividnext.sodalive.configs + +import org.springframework.context.annotation.Configuration +import org.springframework.web.servlet.config.annotation.CorsRegistry +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +@Configuration +class WebConfig : WebMvcConfigurer { + override fun addCorsMappings(registry: CorsRegistry) { + registry.addMapping("/**") + .allowedOrigins( + "http://localhost:8888", + "https://test-admin.sodalive.net", + "https://admin.sodalive.net" + ) + .allowedMethods("*") + .allowCredentials(true) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AddAllPlaybackTrackingRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AddAllPlaybackTrackingRequest.kt new file mode 100644 index 0000000..b31f736 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AddAllPlaybackTrackingRequest.kt @@ -0,0 +1,12 @@ +package kr.co.vividnext.sodalive.content + +data class AddAllPlaybackTrackingRequest( + val timezone: String, + val trackingDataList: List +) + +data class PlaybackTrackingData( + val contentId: Long, + val playDateTime: String, + val isPreview: Boolean +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt new file mode 100644 index 0000000..116d8b4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt @@ -0,0 +1,64 @@ +package kr.co.vividnext.sodalive.content + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.content.hashtag.AudioContentHashTag +import kr.co.vividnext.sodalive.content.main.curation.AudioContentCuration +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.member.Member +import javax.persistence.CascadeType +import javax.persistence.Column +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.OneToMany +import javax.persistence.OneToOne +import javax.persistence.Table + +enum class AudioContentType { + INDIVIDUAL, BUNDLE +} + +enum class SortType { + NEWEST, PRICE_HIGH, PRICE_LOW +} + +@Entity +@Table(name = "content") +data class AudioContent( + var title: String, + @Column(columnDefinition = "TEXT", nullable = false) + var detail: String, + val price: Int = 0, + @Enumerated(value = EnumType.STRING) + val type: AudioContentType = AudioContentType.INDIVIDUAL, + val isGeneratePreview: Boolean = true, + var isAdult: Boolean = false, + var isCommentAvailable: Boolean = true +) : BaseEntity() { + var isActive: Boolean = false + var content: String? = null + var coverImage: String? = null + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "theme_id", nullable = false) + var theme: AudioContentTheme? = null + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "curation_id", nullable = true) + var curation: AudioContentCuration? = null + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + var member: Member? = null + + var duration: String? = null + + @OneToMany(mappedBy = "audioContent", cascade = [CascadeType.ALL]) + val audioContentHashTags: MutableList = mutableListOf() + + @OneToMany(mappedBy = "child", cascade = [CascadeType.ALL]) + var children: MutableList = mutableListOf() +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt new file mode 100644 index 0000000..5a9deda --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt @@ -0,0 +1,151 @@ +package kr.co.vividnext.sodalive.content + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.content.like.PutAudioContentLikeRequest +import kr.co.vividnext.sodalive.member.Member +import org.springframework.data.domain.Pageable +import org.springframework.lang.Nullable +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +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.RequestParam +import org.springframework.web.bind.annotation.RequestPart +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile + +@RestController +@RequestMapping("/audio-content") +class AudioContentController(private val service: AudioContentService) { + @PostMapping + @PreAuthorize("hasRole('CREATOR')") + fun createAudioContent( + @Nullable + @RequestPart("contentFile") + contentFile: MultipartFile?, + @RequestPart("coverImage") coverImage: MultipartFile?, + @RequestPart("request") requestString: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok( + service.createAudioContent( + contentFile = contentFile, + coverImage = coverImage, + requestString = requestString, + member = member + ) + ) + } + + @PutMapping + @PreAuthorize("hasRole('CREATOR')") + fun modifyAudioContent( + @Nullable + @RequestPart("coverImage") + coverImage: MultipartFile?, + @RequestPart("request") requestString: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok( + service.modifyAudioContent( + coverImage = coverImage, + requestString = requestString, + member = member + ) + ) + } + + @PutMapping("/upload-complete") + @PreAuthorize("hasAnyRole('ADMIN', 'BOT')") + fun uploadComplete( + @RequestBody request: UploadCompleteRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok( + service.uploadComplete( + contentId = request.contentId, + content = request.contentPath, + duration = request.duration + ) + ) + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasRole('CREATOR')") + fun deleteAudioContent( + @PathVariable id: Long, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok( + service.deleteAudioContent( + audioContentId = id, + member = member + ) + ) + } + + @GetMapping + fun getAudioContentList( + @RequestParam("creator-id") creatorId: Long, + @RequestParam("sort-type", required = false) sortType: SortType = SortType.NEWEST, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + pageable: Pageable + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok( + service.getAudioContentList( + creatorId = creatorId, + sortType = sortType, + member = member, + offset = pageable.offset, + limit = pageable.pageSize.toLong() + ) + ) + } + + @GetMapping("/{id}") + fun getDetail( + @PathVariable id: Long, + @RequestParam timezone: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.getDetail(id = id, member = member, timezone = timezone)) + } + + @PostMapping("/playback-tracking") + fun addAllPlaybackTracking( + @RequestBody request: AddAllPlaybackTrackingRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.addAllPlaybackTracking(request, member)) + } + + @PutMapping("/like") + fun audioContentLike( + @RequestBody request: PutAudioContentLikeRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.audioContentLike(request, member)) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt new file mode 100644 index 0000000..d29aae7 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt @@ -0,0 +1,347 @@ +package kr.co.vividnext.sodalive.content + +import com.querydsl.core.types.dsl.Expressions +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.content.QAudioContent.audioContent +import kr.co.vividnext.sodalive.content.QBundleAudioContent.bundleAudioContent +import kr.co.vividnext.sodalive.content.main.GetAudioContentMainItem +import kr.co.vividnext.sodalive.content.main.GetNewContentUploadCreator +import kr.co.vividnext.sodalive.content.main.QGetAudioContentMainItem +import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner +import kr.co.vividnext.sodalive.content.main.banner.QAudioContentBanner.audioContentBanner +import kr.co.vividnext.sodalive.content.main.curation.AudioContentCuration +import kr.co.vividnext.sodalive.content.main.curation.QAudioContentCuration.audioContentCuration +import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme +import kr.co.vividnext.sodalive.event.QEvent.event +import kr.co.vividnext.sodalive.member.QMember.member +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import java.time.LocalDateTime + +@Repository +interface AudioContentRepository : JpaRepository, AudioContentQueryRepository + +interface AudioContentQueryRepository { + fun findByIdAndActive(contentId: Long): AudioContent? + fun findByIdAndCreatorId(contentId: Long, creatorId: Long): AudioContent? + fun findBundleByContentId(contentId: Long): List + fun findByCreatorId( + creatorId: Long, + isAdult: Boolean = false, + sortType: SortType = SortType.NEWEST, + offset: Long = 0, + limit: Long = 10 + ): List + + fun findTotalCountByCreatorId(creatorId: Long, isAdult: Boolean = false): Int + fun getCreatorOtherContentList( + cloudfrontHost: String, + contentId: Long, + creatorId: Long, + isAdult: Boolean + ): List + + fun getSameThemeOtherContentList( + cloudfrontHost: String, + contentId: Long, + themeId: Long, + isAdult: Boolean + ): List + + fun findByTheme( + cloudfrontHost: String, + theme: String = "", + isAdult: Boolean = false, + limit: Long = 20 + ): List + + fun getNewContentUploadCreatorList( + cloudfrontHost: String, + isAdult: Boolean = false + ): List + + fun getAudioContentMainBannerList(isAdult: Boolean): List + fun getAudioContentCurations(isAdult: Boolean): List + fun findAudioContentByCurationId( + curationId: Long, + cloudfrontHost: String, + isAdult: Boolean + ): List +} + +@Repository +class AudioContentQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AudioContentQueryRepository { + override fun findByIdAndActive(contentId: Long): AudioContent? { + return queryFactory + .selectFrom(audioContent) + .where( + audioContent.isActive.isTrue + .and(audioContent.id.eq(contentId)) + ) + .fetchOne() + } + + override fun findByIdAndCreatorId(contentId: Long, creatorId: Long): AudioContent? { + return queryFactory + .selectFrom(audioContent) + .where( + audioContent.isActive.isTrue + .and(audioContent.id.eq(contentId)) + .and(audioContent.member.id.eq(creatorId)) + ) + .fetchOne() + } + + // 해당 컨텐츠가 속한 묶음(번들) 상품 리스트 검색 + override fun findBundleByContentId(contentId: Long): List { + return queryFactory + .select(bundleAudioContent.parent) + .from(bundleAudioContent) + .where( + bundleAudioContent.child.id.eq(contentId) + .and(bundleAudioContent.child.isActive.isTrue) + ) + .fetch() + } + + override fun findByCreatorId( + creatorId: Long, + isAdult: Boolean, + sortType: SortType, + offset: Long, + limit: Long + ): List { + val orderBy = when (sortType) { + SortType.NEWEST -> audioContent.createdAt.desc() + SortType.PRICE_HIGH -> audioContent.price.desc() + SortType.PRICE_LOW -> audioContent.price.asc() + } + + var where = audioContent.isActive.isTrue + .and(audioContent.member.id.eq(creatorId)) + + if (!isAdult) { + where = where.and(audioContent.isAdult.isFalse) + } + + return queryFactory + .selectFrom(audioContent) + .where(where) + .offset(offset) + .limit(limit) + .orderBy(orderBy) + .fetch() + } + + override fun findTotalCountByCreatorId( + creatorId: Long, + isAdult: Boolean + ): Int { + var where = audioContent.isActive.isTrue + .and(audioContent.member.id.eq(creatorId)) + + if (!isAdult) { + where = where.and(audioContent.isAdult.isFalse) + } + + return queryFactory + .selectFrom(audioContent) + .where(where) + .fetch() + .size + } + + override fun getCreatorOtherContentList( + cloudfrontHost: String, + contentId: Long, + creatorId: Long, + isAdult: Boolean + ): List { + var where = audioContent.id.ne(contentId) + .and(audioContent.member.id.eq(creatorId)) + .and(audioContent.isActive.isTrue) + + if (!isAdult) { + where = where.and(audioContent.isAdult.isFalse) + } + + return queryFactory + .select( + QOtherContentResponse( + audioContent.id, + audioContent.title, + audioContent.coverImage.prepend("$cloudfrontHost/") + ) + ) + .from(audioContent) + .where(where) + .offset(0) + .limit(10) + .orderBy(Expressions.numberTemplate(Double::class.java, "function('rand')").asc()) + .fetch() + } + + override fun getSameThemeOtherContentList( + cloudfrontHost: String, + contentId: Long, + themeId: Long, + isAdult: Boolean + ): List { + var where = audioContent.id.ne(contentId) + .and(audioContent.theme.id.eq(themeId)) + .and(audioContent.isActive.isTrue) + + if (!isAdult) { + where = where.and(audioContent.isAdult.isFalse) + } + + return queryFactory + .select( + QOtherContentResponse( + audioContent.id, + audioContent.title, + audioContent.coverImage.prepend("$cloudfrontHost/") + ) + ) + .from(audioContent) + .where(where) + .offset(0) + .limit(10) + .orderBy(Expressions.numberTemplate(Double::class.java, "function('rand')").asc()) + .fetch() + } + + override fun findByTheme( + cloudfrontHost: String, + theme: String, + isAdult: Boolean, + limit: Long + ): List { + var where = audioContent.isActive.isTrue + + if (!isAdult) { + where = where.and(audioContent.isAdult.isFalse) + } + + if (theme.isNotBlank()) { + where = where.and(audioContentTheme.theme.eq(theme)) + } + + return queryFactory + .select( + QGetAudioContentMainItem( + audioContent.id, + audioContent.coverImage.prepend("/").prepend(cloudfrontHost), + audioContent.title, + audioContent.isAdult, + member.id, + member.profileImage.prepend("/").prepend(cloudfrontHost), + member.nickname + ) + ) + .from(audioContent) + .innerJoin(audioContent.member, member) + .innerJoin(audioContent.theme, audioContentTheme) + .where(where) + .limit(limit) + .orderBy(audioContent.createdAt.desc()) + .fetch() + } + + override fun getNewContentUploadCreatorList( + cloudfrontHost: String, + isAdult: Boolean + ): List { + var where = audioContent.createdAt.after(LocalDateTime.now().minusWeeks(2)) + .and(audioContent.isActive.isTrue) + + if (!isAdult) { + where = where.and(audioContent.isAdult.isFalse) + } + + return queryFactory + .select(member) + .from(audioContent) + .innerJoin(audioContent.member, member) + .where(where) + .groupBy(member.id) + .orderBy(Expressions.numberTemplate(Double::class.java, "function('rand')").asc()) + .limit(20) + .fetch() + .asSequence() + .map { + GetNewContentUploadCreator( + it.id!!, + it.nickname, + creatorProfileImageUrl = if (it.profileImage != null) { + "$cloudfrontHost/${it.profileImage}" + } else { + "$cloudfrontHost/profile/default-profile.png" + } + ) + } + .toList() + } + + override fun getAudioContentMainBannerList(isAdult: Boolean): List { + var where = audioContentBanner.isActive.isTrue + + if (!isAdult) { + where = where.and(audioContentBanner.isAdult.isFalse) + } + + return queryFactory + .selectFrom(audioContentBanner) + .leftJoin(audioContentBanner.event, event) + .leftJoin(audioContentBanner.creator, member) + .where(where) + .orderBy(audioContentBanner.orders.asc()) + .fetch() + } + + override fun getAudioContentCurations(isAdult: Boolean): List { + var where = audioContentCuration.isActive.isTrue + + if (!isAdult) { + where = where.and(audioContentCuration.isAdult.isFalse) + } + + return queryFactory + .selectFrom(audioContentCuration) + .where(where) + .orderBy(audioContentCuration.orders.asc()) + .fetch() + } + + override fun findAudioContentByCurationId( + curationId: Long, + cloudfrontHost: String, + isAdult: Boolean + ): List { + var where = audioContent.isActive.isTrue + .and(audioContent.curation.id.eq(curationId)) + + if (!isAdult) { + where = where.and(audioContent.isAdult.isFalse) + } + + return queryFactory + .select( + QGetAudioContentMainItem( + audioContent.id, + audioContent.coverImage.prepend("/").prepend(cloudfrontHost), + audioContent.title, + audioContent.isAdult, + member.id, + member.profileImage.nullif("profile/default-profile.png") + .prepend("/") + .prepend(cloudfrontHost), + member.nickname + ) + ) + .from(audioContent) + .where(where) + .orderBy(audioContent.id.desc()) + .fetch() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt new file mode 100644 index 0000000..48dbe84 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -0,0 +1,497 @@ +package kr.co.vividnext.sodalive.content + +import com.amazonaws.services.s3.model.ObjectMetadata +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository +import kr.co.vividnext.sodalive.content.hashtag.AudioContentHashTag +import kr.co.vividnext.sodalive.content.hashtag.HashTag +import kr.co.vividnext.sodalive.content.hashtag.HashTagRepository +import kr.co.vividnext.sodalive.content.like.AudioContentLike +import kr.co.vividnext.sodalive.content.like.AudioContentLikeRepository +import kr.co.vividnext.sodalive.content.like.PutAudioContentLikeRequest +import kr.co.vividnext.sodalive.content.like.PutAudioContentLikeResponse +import kr.co.vividnext.sodalive.content.order.OrderRepository +import kr.co.vividnext.sodalive.content.order.OrderType +import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository +import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository +import kr.co.vividnext.sodalive.fcm.FcmEvent +import kr.co.vividnext.sodalive.fcm.FcmEventType +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.block.BlockMemberRepository +import kr.co.vividnext.sodalive.utils.generateFileName +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.ApplicationEventPublisher +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 +@Transactional(readOnly = true) +class AudioContentService( + private val repository: AudioContentRepository, + private val explorerQueryRepository: ExplorerQueryRepository, + private val blockMemberRepository: BlockMemberRepository, + private val hashTagRepository: HashTagRepository, + private val orderRepository: OrderRepository, + private val themeQueryRepository: AudioContentThemeQueryRepository, + private val playbackTrackingRepository: PlaybackTrackingRepository, + private val commentRepository: AudioContentCommentRepository, + private val audioContentLikeRepository: AudioContentLikeRepository, + + private val s3Uploader: S3Uploader, + private val objectMapper: ObjectMapper, + private val audioContentCloudFront: AudioContentCloudFront, + private val applicationEventPublisher: ApplicationEventPublisher, + + @Value("\${cloud.aws.s3.content-bucket}") + private val audioContentBucket: String, + + @Value("\${cloud.aws.s3.bucket}") + private val coverImageBucket: String, + + @Value("\${cloud.aws.cloud-front.host}") + private val coverImageHost: String +) { + @Transactional + fun audioContentLike(request: PutAudioContentLikeRequest, member: Member): PutAudioContentLikeResponse { + var audioContentLike = audioContentLikeRepository.findByMemberIdAndContentId( + memberId = member.id!!, + contentId = request.contentId + ) + + if (audioContentLike == null) { + audioContentLike = AudioContentLike( + memberId = member.id!!, + contentId = request.contentId + ) + + audioContentLikeRepository.save(audioContentLike) + } else { + audioContentLike.isActive = !audioContentLike.isActive + } + + return PutAudioContentLikeResponse(like = audioContentLike.isActive) + } + + @Transactional + fun modifyAudioContent( + coverImage: MultipartFile?, + requestString: String, + member: Member + ) { + // request 내용 파싱 + val request = objectMapper.readValue(requestString, ModifyAudioContentRequest::class.java) + + val audioContent = repository.findByIdAndCreatorId(request.contentId, member.id!!) + ?: throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.") + + if (request.title != null) audioContent.title = request.title + if (request.detail != null) audioContent.detail = request.detail + audioContent.isCommentAvailable = request.isCommentAvailable + audioContent.isAdult = request.isAdult + + if (coverImage != null) { + val metadata = ObjectMetadata() + metadata.contentLength = coverImage.size + + // 커버 이미지 파일명 생성 + val coverImageFileName = generateFileName(prefix = "${audioContent.id}-cover") + + // 커버 이미지 업로드 + val coverImagePath = s3Uploader.upload( + inputStream = coverImage.inputStream, + bucket = coverImageBucket, + filePath = "audio_content_cover/${audioContent.id}/$coverImageFileName", + metadata = metadata + ) + + audioContent.coverImage = coverImagePath + } + } + + @Transactional + fun deleteAudioContent(audioContentId: Long, member: Member) { + val audioContent = repository.findByIdAndCreatorId(audioContentId, member.id!!) + ?: throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.") + + audioContent.isActive = false + } + + @Transactional + fun createAudioContent( + contentFile: MultipartFile?, + coverImage: MultipartFile?, + requestString: String, + member: Member + ): CreateAudioContentResponse { + // coverImage 체크 + if (coverImage == null) throw SodaException("커버이미지를 선택해 주세요.") + + // request 내용 파싱 + val request = objectMapper.readValue(requestString, CreateAudioContentRequest::class.java) + + // contentFile 체크 + if (contentFile == null && request.type == AudioContentType.INDIVIDUAL) { + throw SodaException("콘텐츠를 선택해 주세요.") + } + + if (request.type == AudioContentType.BUNDLE && request.childIds == null) { + throw SodaException("묶음상품의 하위상품을 선택해 주세요.") + } + + // 테마 체크 + val theme = themeQueryRepository.findThemeByIdAndActive(id = request.themeId) + ?: throw SodaException("잘못된 테마입니다. 다시 선택해 주세요.") + + if (request.price in 1..9) throw SodaException("콘텐츠의 최소금액은 10캔 입니다.") + + // DB에 값 추가 + val audioContent = AudioContent( + title = request.title, + detail = request.detail, + type = request.type, + price = if (request.price < 0) { + 0 + } else { + request.price + }, + isAdult = request.isAdult, + isGeneratePreview = if (request.type == AudioContentType.INDIVIDUAL) { + request.isGeneratePreview + } else { + false + }, + isCommentAvailable = request.isCommentAvailable + ) + audioContent.theme = theme + audioContent.member = member + audioContent.isActive = request.type == AudioContentType.BUNDLE + + repository.save(audioContent) + + // 태그 분리, #추가, 등록 + if (request.tags.isNotBlank()) { + val tags = request.tags + .split(" ") + .asSequence() + .map { it.trim() } + .filter { it.isNotEmpty() } + .map { + val tag = if (!it.startsWith("#")) { + "#$it" + } else { + it + } + + val hashTag = hashTagRepository.findByTag(tag) + ?: hashTagRepository.save(HashTag(tag)) + + val audioContentHashTag = AudioContentHashTag() + audioContentHashTag.audioContent = audioContent + audioContentHashTag.hashTag = hashTag + + audioContentHashTag + }.toList() + + audioContent.audioContentHashTags.addAll(tags) + } + + var metadata = ObjectMetadata() + metadata.contentLength = coverImage.size + + // 커버 이미지 파일명 생성 + val coverImageFileName = generateFileName(prefix = "${audioContent.id}-cover") + + // 커버 이미지 업로드 + val coverImagePath = s3Uploader.upload( + inputStream = coverImage.inputStream, + bucket = coverImageBucket, + filePath = "audio_content_cover/${audioContent.id}/$coverImageFileName", + metadata = metadata + ) + + audioContent.coverImage = coverImagePath + + if (contentFile != null && request.type == AudioContentType.INDIVIDUAL) { + // 콘텐츠 파일명 생성 + val contentFileName = generateFileName(prefix = "${audioContent.id}-content") + + // 콘텐츠 파일 업로드 + metadata = ObjectMetadata() + metadata.contentLength = contentFile.size + metadata.addUserMetadata("generate_preview", "true") + + val contentPath = s3Uploader.upload( + inputStream = contentFile.inputStream, + bucket = audioContentBucket, + filePath = "input/${audioContent.id}/$contentFileName", + metadata = metadata + ) + + audioContent.content = contentPath + } + + if (request.childIds != null && request.type == AudioContentType.BUNDLE) { + for (childId in request.childIds) { + val childContent = repository.findByIdAndActive(childId) + ?: continue + + val bundleAudioContent = BundleAudioContent() + bundleAudioContent.parent = audioContent + bundleAudioContent.child = childContent + audioContent.children.add(bundleAudioContent) + } + } + + return CreateAudioContentResponse(contentId = audioContent.id!!) + } + + @Transactional + fun uploadComplete(contentId: Long, content: String, duration: String) { + val keyFileName = content.split("/").last() + if (!keyFileName.startsWith(contentId.toString())) throw SodaException("잘못된 요청입니다.") + + val audioContent = repository.findByIdOrNull(contentId) + ?: throw SodaException("잘못된 요청입니다.") + + audioContent.isActive = true + audioContent.content = content + audioContent.duration = duration + + applicationEventPublisher.publishEvent( + FcmEvent( + type = FcmEventType.UPLOAD_CONTENT, + title = audioContent.member!!.nickname, + message = "콘텐츠를 업로드 하였습니다. - ${audioContent.title}", + isAuth = audioContent.isAdult, + contentId = contentId, + creatorId = audioContent.member!!.id, + container = "ios" + ) + ) + + applicationEventPublisher.publishEvent( + FcmEvent( + type = FcmEventType.UPLOAD_CONTENT, + title = audioContent.member!!.nickname, + message = "콘텐츠를 업로드 하였습니다. - ${audioContent.title}", + isAuth = audioContent.isAdult, + contentId = contentId, + creatorId = audioContent.member!!.id, + container = "aos" + ) + ) + } + + fun getDetail(id: Long, member: Member, timezone: String): GetAudioContentDetailResponse { + // 묶음 콘텐츠 조회 + val bundleAudioContentList = repository.findBundleByContentId(contentId = id) + + // 오디오 콘텐츠 조회 (content_id, 제목, 내용, 테마, 태그, 19여부, 이미지, 콘텐츠 PATH) + val audioContent = repository.findByIdOrNull(id) + ?: throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.") + + // 크리에이터(유저) 정보 + val creatorId = audioContent.member!!.id!! + val creator = explorerQueryRepository.getAccount(creatorId) + ?: throw SodaException("없는 사용자 입니다.") + + val notificationUserIds = explorerQueryRepository.getNotificationUserIds(creatorId) + val isFollowing = notificationUserIds.contains(member.id) + + // 차단된 사용자 체크 + val isBlocked = blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = creatorId) + if (isBlocked) throw SodaException("${creator.nickname}님의 요청으로 콘텐츠 접근이 제한됩니다.") + + // 구매 여부 확인 + val isExistsBundleAudioContent = bundleAudioContentList + .map { orderRepository.isExistOrdered(memberId = member.id!!, contentId = it.id!!) } + .contains(true) + + val (isExistsAudioContent, orderType) = orderRepository.isExistOrderedAndOrderType( + memberId = member.id!!, + contentId = audioContent.id!! + ) + + if (!isExistsAudioContent && !isExistsBundleAudioContent && !audioContent.isActive) { + throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.") + } + + // 댓글 + val commentList = if (audioContent.isCommentAvailable) { + commentRepository.findByContentId( + cloudFrontHost = coverImageHost, + contentId = audioContent.id!!, + timezone = timezone, + offset = 0, + limit = 1 + ) + } else { + listOf() + } + + // 댓글 수 + val commentCount = if (audioContent.isCommentAvailable) { + commentRepository.totalCountCommentByContentId(contentId = audioContent.id!!) + } else { + 0 + } + + val audioContentUrl = audioContentCloudFront.generateSignedURL( + resourcePath = if ( + isExistsAudioContent || + isExistsBundleAudioContent || + audioContent.member!!.id!! == member.id!! || + audioContent.price <= 0 + ) { + audioContent.content!! + } else { + audioContent.content!!.replace("output/", "preview/") + }, + expirationTime = 1000 * 60 * 60 * (audioContent.duration!!.split(":")[0].toLong() + 2) + ) + + val tag = audioContent.audioContentHashTags + .map { it.hashTag!!.tag } + .joinToString(" ") { it } + + val creatorOtherContentList = repository.getCreatorOtherContentList( + cloudfrontHost = coverImageHost, + contentId = audioContent.id!!, + creatorId = creatorId, + isAdult = member.auth != null + ) + + val sameThemeOtherContentList = repository.getSameThemeOtherContentList( + cloudfrontHost = coverImageHost, + contentId = audioContent.id!!, + themeId = audioContent.theme!!.id!!, + isAdult = member.auth != null + ) + + val likeCount = audioContentLikeRepository.totalCountAudioContentLike(contentId = id) + val isLike = audioContentLikeRepository.findByMemberIdAndContentId( + memberId = member.id!!, + contentId = id + )?.isActive ?: false + + val remainingTime = if (orderType == OrderType.RENTAL) { + orderRepository.getAudioContentRemainingTime( + memberId = member.id!!, + contentId = audioContent.id!!, + timezone = timezone + ) + } else { + null + } + + return GetAudioContentDetailResponse( + contentId = audioContent.id!!, + title = audioContent.title, + detail = audioContent.detail, + coverImageUrl = "$coverImageHost/${audioContent.coverImage!!}", + contentUrl = audioContentUrl, + themeStr = audioContent.theme!!.theme, + tag = tag, + price = audioContent.price, + duration = audioContent.duration ?: "", + isAdult = audioContent.isAdult, + isMosaic = audioContent.isAdult && member.auth == null, + existOrdered = isExistsBundleAudioContent || isExistsAudioContent, + orderType = orderType, + remainingTime = remainingTime, + creatorOtherContentList = creatorOtherContentList, + sameThemeOtherContentList = sameThemeOtherContentList, + isCommentAvailable = audioContent.isCommentAvailable, + isLike = isLike, + likeCount = likeCount, + commentList = commentList, + commentCount = commentCount, + creator = AudioContentCreator( + creatorId = creatorId, + nickname = creator.nickname, + profileImageUrl = if (creator.profileImage != null) { + "$coverImageHost/${creator.profileImage}" + } else { + "$coverImageHost/profile/default-profile.png" + }, + isFollowing = isFollowing + ) + ) + } + + fun getAudioContentList( + creatorId: Long, + sortType: SortType, + member: Member, + offset: Long, + limit: Long + ): GetAudioContentListResponse { + val totalCount = repository.findTotalCountByCreatorId( + creatorId = creatorId, + isAdult = member.auth != null + ) + + val audioContentList = repository.findByCreatorId( + creatorId = creatorId, + isAdult = member.auth != null, + sortType = sortType, + offset = offset, + limit = limit + ) + + val items = audioContentList + .map { + val commentCount = commentRepository + .totalCountCommentByContentId(it.id!!) + + val likeCount = audioContentLikeRepository + .totalCountAudioContentLike(it.id!!) + + GetAudioContentListItem( + contentId = it.id!!, + coverImageUrl = "$coverImageHost/${it.coverImage!!}", + title = it.title, + price = it.price, + themeStr = it.theme!!.theme, + duration = it.duration, + likeCount = likeCount, + commentCount = commentCount, + isAdult = it.isAdult + ) + } + + return GetAudioContentListResponse( + totalCount = totalCount, + items = items + ) + } + + @Transactional + fun addAllPlaybackTracking(request: AddAllPlaybackTrackingRequest, member: Member) { + val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + + for (trackingData in request.trackingDataList) { + val playDate = LocalDateTime.parse(trackingData.playDateTime, dateTimeFormatter) + .atZone(ZoneId.of(request.timezone)) + .withZoneSameInstant(ZoneId.of("UTC")) + .toLocalDateTime() + + playbackTrackingRepository.save( + PlaybackTracking( + memberId = member.id!!, + contentId = trackingData.contentId, + playDate = playDate, + isPreview = trackingData.isPreview + ) + ) + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/BundleAudioContent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/BundleAudioContent.kt new file mode 100644 index 0000000..375e819 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/BundleAudioContent.kt @@ -0,0 +1,22 @@ +package kr.co.vividnext.sodalive.content + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne +import javax.persistence.Table + +@Entity +@Table(name = "bundle_content") +data class BundleAudioContent( + var isActive: Boolean = true +) : BaseEntity() { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_content_id", nullable = false) + var parent: AudioContent? = null + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "child_content_id", nullable = false) + var child: AudioContent? = null +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/CreateAudioContentRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/CreateAudioContentRequest.kt new file mode 100644 index 0000000..8952c84 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/CreateAudioContentRequest.kt @@ -0,0 +1,14 @@ +package kr.co.vividnext.sodalive.content + +data class CreateAudioContentRequest( + val title: String, + val detail: String, + val tags: String, + val price: Int, + val themeId: Long = 0, + val isAdult: Boolean = false, + val isGeneratePreview: Boolean = true, + val isCommentAvailable: Boolean = false, + val type: AudioContentType = AudioContentType.INDIVIDUAL, + val childIds: List? = null +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/CreateAudioContentResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/CreateAudioContentResponse.kt new file mode 100644 index 0000000..3bffecf --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/CreateAudioContentResponse.kt @@ -0,0 +1,3 @@ +package kr.co.vividnext.sodalive.content + +data class CreateAudioContentResponse(val contentId: Long) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/GetAudioContentDetailResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/GetAudioContentDetailResponse.kt new file mode 100644 index 0000000..0c5f3e9 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/GetAudioContentDetailResponse.kt @@ -0,0 +1,43 @@ +package kr.co.vividnext.sodalive.content + +import com.querydsl.core.annotations.QueryProjection +import kr.co.vividnext.sodalive.content.comment.GetAudioContentCommentListItem +import kr.co.vividnext.sodalive.content.order.OrderType + +data class GetAudioContentDetailResponse( + val contentId: Long, + val title: String, + val detail: String, + val coverImageUrl: String, + val contentUrl: String, + val themeStr: String, + val tag: String, + val price: Int, + val duration: String, + val isAdult: Boolean, + val isMosaic: Boolean, + val existOrdered: Boolean, + val orderType: OrderType?, + val remainingTime: String?, + val creatorOtherContentList: List, + val sameThemeOtherContentList: List, + val isCommentAvailable: Boolean, + val isLike: Boolean, + val likeCount: Int, + val commentList: List, + val commentCount: Int, + val creator: AudioContentCreator +) + +data class OtherContentResponse @QueryProjection constructor( + val contentId: Long, + val title: String, + val coverUrl: String +) + +data class AudioContentCreator( + val creatorId: Long, + val nickname: String, + val profileImageUrl: String, + val isFollowing: Boolean +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/GetAudioContentListResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/GetAudioContentListResponse.kt new file mode 100644 index 0000000..bfef003 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/GetAudioContentListResponse.kt @@ -0,0 +1,18 @@ +package kr.co.vividnext.sodalive.content + +data class GetAudioContentListResponse( + val totalCount: Int, + val items: List +) + +data class GetAudioContentListItem( + val contentId: Long, + val coverImageUrl: String, + val title: String, + val price: Int, + val themeStr: String, + val duration: String?, + val likeCount: Int, + val commentCount: Int, + val isAdult: Boolean +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/ModifyAudioContentRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/ModifyAudioContentRequest.kt new file mode 100644 index 0000000..fc104f7 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/ModifyAudioContentRequest.kt @@ -0,0 +1,9 @@ +package kr.co.vividnext.sodalive.content + +data class ModifyAudioContentRequest( + val contentId: Long, + val title: String?, + val detail: String?, + val isAdult: Boolean, + val isCommentAvailable: Boolean +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/PlaybackTracking.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/PlaybackTracking.kt new file mode 100644 index 0000000..2639f37 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/PlaybackTracking.kt @@ -0,0 +1,26 @@ +package kr.co.vividnext.sodalive.content + +import java.time.LocalDateTime +import javax.persistence.Entity +import javax.persistence.GeneratedValue +import javax.persistence.GenerationType +import javax.persistence.Id +import javax.persistence.PrePersist + +@Entity +data class PlaybackTracking( + val memberId: Long, + val contentId: Long, + val isPreview: Boolean, + val playDate: LocalDateTime +) { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null + var createdAt: LocalDateTime? = null + + @PrePersist + fun prePersist() { + createdAt = LocalDateTime.now() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/PlaybackTrackingRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/PlaybackTrackingRepository.kt new file mode 100644 index 0000000..f768729 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/PlaybackTrackingRepository.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.content + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface PlaybackTrackingRepository : JpaRepository diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/UploadCompleteRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/UploadCompleteRequest.kt new file mode 100644 index 0000000..75a4898 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/UploadCompleteRequest.kt @@ -0,0 +1,3 @@ +package kr.co.vividnext.sodalive.content + +data class UploadCompleteRequest(val contentId: Long, val contentPath: String, val duration: String) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentComment.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentComment.kt new file mode 100644 index 0000000..0f345e3 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentComment.kt @@ -0,0 +1,41 @@ +package kr.co.vividnext.sodalive.content.comment + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.member.Member +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne +import javax.persistence.OneToMany +import javax.persistence.Table + +@Entity +@Table(name = "content_comment") +data class AudioContentComment( + @Column(columnDefinition = "TEXT", nullable = false) + var comment: String, + @Column(nullable = true) + var donationCan: Int? = null, + var isActive: Boolean = true +) : BaseEntity() { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_id", nullable = true) + var parent: AudioContentComment? = null + set(value) { + value?.children?.add(this) + field = value + } + + @OneToMany(mappedBy = "parent") + var children: MutableList = mutableListOf() + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + var member: Member? = null + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id", nullable = false) + var audioContent: AudioContent? = null +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentController.kt new file mode 100644 index 0000000..dc153e2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentController.kt @@ -0,0 +1,80 @@ +package kr.co.vividnext.sodalive.content.comment + +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.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.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +class AudioContentCommentController(private val service: AudioContentCommentService) { + @PostMapping("/audio-content/comment") + fun registerComment( + @RequestBody request: RegisterCommentRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok( + service.registerComment( + comment = request.comment, + audioContentId = request.contentId, + parentId = request.parentId, + member = member + ) + ) + } + + @PutMapping("/audio-content/comment") + fun modifyComment( + @RequestBody request: ModifyCommentRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.modifyComment(request = request, member = member)) + } + + @GetMapping("/audio-content/{id}/comment") + fun getCommentList( + @PathVariable("id") audioContentId: Long, + @RequestParam timezone: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + pageable: Pageable + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok( + service.getCommentList( + audioContentId = audioContentId, + timezone = timezone, + pageable = pageable + ) + ) + } + + @GetMapping("/audio-content/comment/{id}") + fun getCommentReplyList( + @PathVariable("id") commentId: Long, + @RequestParam timezone: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + pageable: Pageable + ): ApiResponse { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + return ApiResponse.ok( + service.getCommentReplyList( + commentId = commentId, + timezone = timezone, + pageable = pageable + ) + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentRepository.kt new file mode 100644 index 0000000..b58aa2d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentRepository.kt @@ -0,0 +1,141 @@ +package kr.co.vividnext.sodalive.content.comment + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.content.comment.QAudioContentComment.audioContentComment +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +@Repository +interface AudioContentCommentRepository : JpaRepository, AudioContentCommentQueryRepository + +interface AudioContentCommentQueryRepository { + fun findByContentId( + cloudFrontHost: String, + contentId: Long, + timezone: String, + offset: Long, + limit: Int + ): List + + fun totalCountCommentByContentId(contentId: Long): Int + fun commentReplyCountByAudioContentCommentId(commentId: Long): Int + fun getAudioContentCommentReplyList( + cloudFrontHost: String, + commentId: Long, + timezone: String, + offset: Long, + limit: Int + ): List +} + +@Repository +class AudioContentCommentQueryRepositoryImpl( + private val queryFactory: JPAQueryFactory +) : AudioContentCommentQueryRepository { + override fun findByContentId( + cloudFrontHost: String, + contentId: Long, + timezone: String, + offset: Long, + limit: Int + ): List { + return queryFactory.selectFrom(audioContentComment) + .where( + audioContentComment.audioContent.id.eq(contentId) + .and(audioContentComment.isActive.isTrue) + .and(audioContentComment.parent.isNull) + ) + .offset(offset) + .limit(limit.toLong()) + .orderBy(audioContentComment.createdAt.desc()) + .fetch() + .asSequence() + .map { + val date = it.createdAt!! + .atZone(ZoneId.of("UTC")) + .withZoneSameInstant(ZoneId.of(timezone)) + + GetAudioContentCommentListItem( + id = it.id!!, + writerId = it.member!!.id!!, + nickname = it.member!!.nickname, + profileUrl = if (it.member!!.profileImage != null) { + "$cloudFrontHost/${it.member!!.profileImage}" + } else { + "$cloudFrontHost/profile/default-profile.png" + }, + comment = it.comment, + donationCan = it.donationCan ?: 0, + date = date.format(DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a")), + replyCount = commentReplyCountByAudioContentCommentId(it.id!!) + ) + } + .toList() + } + + override fun totalCountCommentByContentId(contentId: Long): Int { + return queryFactory.select(audioContentComment.id) + .from(audioContentComment) + .where( + audioContentComment.audioContent.id.eq(contentId) + .and(audioContentComment.isActive.isTrue) + ) + .fetch() + .size + } + + override fun commentReplyCountByAudioContentCommentId(commentId: Long): Int { + return queryFactory.select(audioContentComment.id) + .from(audioContentComment) + .where( + audioContentComment.parent.isNotNull + .and(audioContentComment.parent.id.eq(commentId)) + .and(audioContentComment.isActive.isTrue) + ) + .fetch() + .size + } + + override fun getAudioContentCommentReplyList( + cloudFrontHost: String, + commentId: Long, + timezone: String, + offset: Long, + limit: Int + ): List { + return queryFactory.selectFrom(audioContentComment) + .where( + audioContentComment.parent.isNotNull + .and(audioContentComment.parent.id.eq(commentId)) + .and(audioContentComment.isActive.isTrue) + ) + .offset(offset) + .limit(limit.toLong()) + .orderBy(audioContentComment.createdAt.desc()) + .fetch() + .asSequence() + .map { + val date = it.createdAt!! + .atZone(ZoneId.of("UTC")) + .withZoneSameInstant(ZoneId.of(timezone)) + + GetAudioContentCommentListItem( + id = it.id!!, + writerId = it.member!!.id!!, + nickname = it.member!!.nickname, + profileUrl = if (it.member!!.profileImage != null) { + "$cloudFrontHost/${it.member!!.profileImage}" + } else { + "$cloudFrontHost/profile/default-profile.png" + }, + comment = it.comment, + donationCan = it.donationCan ?: 0, + date = date.format(DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a")), + replyCount = 0 + ) + } + .toList() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt new file mode 100644 index 0000000..dd91014 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt @@ -0,0 +1,89 @@ +package kr.co.vividnext.sodalive.content.comment + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.content.AudioContentRepository +import kr.co.vividnext.sodalive.member.Member +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 +@Transactional(readOnly = true) +class AudioContentCommentService( + private val repository: AudioContentCommentRepository, + private val audioContentRepository: AudioContentRepository, + + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) { + @Transactional + fun registerComment(member: Member, comment: String, audioContentId: Long, parentId: Long? = null) { + val audioContent = audioContentRepository.findByIdOrNull(id = audioContentId) + ?: throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.") + + val audioContentComment = AudioContentComment(comment = comment) + audioContentComment.audioContent = audioContent + audioContentComment.member = member + + val parent = if (parentId != null) { + repository.findByIdOrNull(id = parentId) + } else { + null + } + + if (parent != null) { + audioContentComment.parent = parent + } + + repository.save(audioContentComment) + } + + @Transactional + fun modifyComment(request: ModifyCommentRequest, member: Member) { + val audioContentComment = repository.findByIdOrNull(request.commentId) + ?: throw SodaException("잘못된 접근 입니다.\n확인 후 다시 시도해 주세요.") + + if (audioContentComment.audioContent!!.member!!.id!! != member.id!!) { + if (audioContentComment.member == null || audioContentComment.member!!.id!! != member.id!!) { + throw SodaException("잘못된 접근 입니다.\n확인 후 다시 시도해 주세요.") + } + + if (request.comment != null) { + audioContentComment.comment = request.comment + } + } + + if (request.isActive != null) { + audioContentComment.isActive = request.isActive + } + } + + fun getCommentList(audioContentId: Long, timezone: String, pageable: Pageable): GetAudioContentCommentListResponse { + val commentList = + repository.findByContentId( + cloudFrontHost = cloudFrontHost, + contentId = audioContentId, + timezone = timezone, + offset = pageable.offset, + limit = pageable.pageSize + ) + val totalCount = repository.totalCountCommentByContentId(audioContentId) + + return GetAudioContentCommentListResponse(totalCount, commentList) + } + + fun getCommentReplyList(commentId: Long, timezone: String, pageable: Pageable): GetAudioContentCommentListResponse { + val commentList = repository.getAudioContentCommentReplyList( + cloudFrontHost = cloudFrontHost, + commentId = commentId, + timezone = timezone, + offset = pageable.offset, + limit = pageable.pageSize + ) + val totalCount = repository.commentReplyCountByAudioContentCommentId(commentId) + + return GetAudioContentCommentListResponse(totalCount, commentList) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/GetAudioContentCommentListResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/GetAudioContentCommentListResponse.kt new file mode 100644 index 0000000..bbdd725 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/GetAudioContentCommentListResponse.kt @@ -0,0 +1,17 @@ +package kr.co.vividnext.sodalive.content.comment + +data class GetAudioContentCommentListResponse( + val totalCount: Int, + val items: List +) + +data class GetAudioContentCommentListItem( + val id: Long, + val writerId: Long, + val nickname: String, + val profileUrl: String, + val comment: String, + val donationCan: Int, + val date: String, + val replyCount: Int +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/ModifyCommentRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/ModifyCommentRequest.kt new file mode 100644 index 0000000..574baee --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/ModifyCommentRequest.kt @@ -0,0 +1,3 @@ +package kr.co.vividnext.sodalive.content.comment + +data class ModifyCommentRequest(val commentId: Long, val comment: String? = null, val isActive: Boolean? = null) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/RegisterCommentRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/RegisterCommentRequest.kt new file mode 100644 index 0000000..df29747 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/RegisterCommentRequest.kt @@ -0,0 +1,3 @@ +package kr.co.vividnext.sodalive.content.comment + +data class RegisterCommentRequest(val comment: String, val contentId: Long, val parentId: Long?) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationController.kt new file mode 100644 index 0000000..ebd097a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationController.kt @@ -0,0 +1,25 @@ +package kr.co.vividnext.sodalive.content.donation + +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.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("/audio-content/donation") +class AudioContentDonationController(private val service: AudioContentDonationService) { + + @PostMapping + fun donation( + @RequestBody request: AudioContentDonationRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.donation(request = request, member = member)) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationRequest.kt new file mode 100644 index 0000000..a8ed8a9 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationRequest.kt @@ -0,0 +1,8 @@ +package kr.co.vividnext.sodalive.content.donation + +data class AudioContentDonationRequest( + val contentId: Long, + val donationCan: Int, + val comment: String, + val container: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationService.kt new file mode 100644 index 0000000..d8e1e46 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationService.kt @@ -0,0 +1,43 @@ +package kr.co.vividnext.sodalive.content.donation + +import kr.co.vividnext.sodalive.can.payment.CanPaymentService +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.content.AudioContentRepository +import kr.co.vividnext.sodalive.content.comment.AudioContentComment +import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository +import kr.co.vividnext.sodalive.member.Member +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class AudioContentDonationService( + private val canPaymentService: CanPaymentService, + private val queryRepository: AudioContentRepository, + private val commentRepository: AudioContentCommentRepository +) { + @Transactional + fun donation(request: AudioContentDonationRequest, member: Member) { + if (request.donationCan < 1) throw SodaException("1캔 이상 후원하실 수 있습니다.") + if (request.comment.isBlank()) throw SodaException("함께 보낼 메시지를 입력하세요.") + + val audioContent = queryRepository.findByIdAndActive(request.contentId) + ?: throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.") + + canPaymentService.spendCan( + memberId = member.id!!, + needCan = request.donationCan, + canUsage = CanUsage.DONATION, + audioContent = audioContent, + container = request.container + ) + + val audioContentComment = AudioContentComment( + comment = request.comment, + donationCan = request.donationCan + ) + audioContentComment.audioContent = audioContent + audioContentComment.member = member + commentRepository.save(audioContentComment) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/hashtag/AudioContentHashTag.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/hashtag/AudioContentHashTag.kt new file mode 100644 index 0000000..0ff2b6d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/hashtag/AudioContentHashTag.kt @@ -0,0 +1,36 @@ +package kr.co.vividnext.sodalive.content.hashtag + +import kr.co.vividnext.sodalive.content.AudioContent +import java.time.LocalDateTime +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.GeneratedValue +import javax.persistence.GenerationType +import javax.persistence.Id +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne +import javax.persistence.PrePersist +import javax.persistence.Table + +@Entity +@Table(name = "content_hash_tag") +class AudioContentHashTag { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null + + var createdAt: LocalDateTime? = null + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id", nullable = false) + var audioContent: AudioContent? = null + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "hash_tag_id", nullable = false) + var hashTag: HashTag? = null + + @PrePersist + fun prePersist() { + createdAt = LocalDateTime.now() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/hashtag/HashTag.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/hashtag/HashTag.kt new file mode 100644 index 0000000..118e081 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/hashtag/HashTag.kt @@ -0,0 +1,23 @@ +package kr.co.vividnext.sodalive.content.hashtag + +import java.time.LocalDateTime +import javax.persistence.Entity +import javax.persistence.GeneratedValue +import javax.persistence.GenerationType +import javax.persistence.Id +import javax.persistence.PrePersist + +@Entity +class HashTag( + val tag: String +) { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Int? = null + var createdAt: LocalDateTime? = null + + @PrePersist + fun prePersist() { + createdAt = LocalDateTime.now() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/hashtag/HashTagRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/hashtag/HashTagRepository.kt new file mode 100644 index 0000000..93edd35 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/hashtag/HashTagRepository.kt @@ -0,0 +1,23 @@ +package kr.co.vividnext.sodalive.content.hashtag + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.content.hashtag.QHashTag.hashTag +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface HashTagRepository : JpaRepository, HashTagQueryRepository + +interface HashTagQueryRepository { + fun findByTag(tag: String): HashTag? +} + +@Repository +class HashTagQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : HashTagQueryRepository { + override fun findByTag(tag: String): HashTag? { + return queryFactory + .selectFrom(hashTag) + .where(hashTag.tag.eq(tag)) + .fetchOne() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/like/AudioContentLike.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/like/AudioContentLike.kt new file mode 100644 index 0000000..5b9b16a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/like/AudioContentLike.kt @@ -0,0 +1,37 @@ +package kr.co.vividnext.sodalive.content.like + +import java.time.LocalDateTime +import javax.persistence.Entity +import javax.persistence.GeneratedValue +import javax.persistence.GenerationType +import javax.persistence.Id +import javax.persistence.PrePersist +import javax.persistence.PreUpdate +import javax.persistence.Table + +@Entity +@Table(name = "content_like") +data class AudioContentLike( + val memberId: Long, + val contentId: Long +) { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null + + var createdAt: LocalDateTime? = null + var updatedAt: LocalDateTime? = null + + @PrePersist + fun prePersist() { + createdAt = LocalDateTime.now() + updatedAt = LocalDateTime.now() + } + + @PreUpdate + fun preUpdate() { + updatedAt = LocalDateTime.now() + } + + var isActive = true +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/like/AudioContentLikeRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/like/AudioContentLikeRepository.kt new file mode 100644 index 0000000..db8b1ad --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/like/AudioContentLikeRepository.kt @@ -0,0 +1,39 @@ +package kr.co.vividnext.sodalive.content.like + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.content.like.QAudioContentLike.audioContentLike +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface AudioContentLikeRepository : JpaRepository, AudioContentLikeQueryRepository + +interface AudioContentLikeQueryRepository { + fun findByMemberIdAndContentId(memberId: Long, contentId: Long): AudioContentLike? + fun totalCountAudioContentLike(contentId: Long): Int +} + +@Repository +class AudioContentLikeQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AudioContentLikeQueryRepository { + override fun findByMemberIdAndContentId(memberId: Long, contentId: Long): AudioContentLike? { + return queryFactory + .selectFrom(audioContentLike) + .where( + audioContentLike.memberId.eq(memberId) + .and(audioContentLike.contentId.eq(contentId)) + ) + .fetchFirst() + } + + override fun totalCountAudioContentLike(contentId: Long): Int { + return queryFactory + .select(audioContentLike.id) + .from(audioContentLike) + .where( + audioContentLike.contentId.eq(contentId) + .and(audioContentLike.isActive.isTrue) + ) + .fetch() + .size + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/like/PutAudioContentLikeRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/like/PutAudioContentLikeRequest.kt new file mode 100644 index 0000000..5bc9df2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/like/PutAudioContentLikeRequest.kt @@ -0,0 +1,3 @@ +package kr.co.vividnext.sodalive.content.like + +data class PutAudioContentLikeRequest(val contentId: Long) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/like/PutAudioContentLikeResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/like/PutAudioContentLikeResponse.kt new file mode 100644 index 0000000..55698e2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/like/PutAudioContentLikeResponse.kt @@ -0,0 +1,3 @@ +package kr.co.vividnext.sodalive.content.like + +data class PutAudioContentLikeResponse(val like: Boolean) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainController.kt new file mode 100644 index 0000000..d021553 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainController.kt @@ -0,0 +1,34 @@ +package kr.co.vividnext.sodalive.content.main + +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.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("/audio-content/main") +class AudioContentMainController(private val service: AudioContentMainService) { + + @GetMapping + fun getMain( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.getMain(member = member)) + } + + @GetMapping("/new") + fun getNewContentByTheme( + @RequestParam("theme") theme: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.getNewContentByTheme(theme, member)) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainService.kt new file mode 100644 index 0000000..8c01c2a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainService.kt @@ -0,0 +1,147 @@ +package kr.co.vividnext.sodalive.content.main + +import kr.co.vividnext.sodalive.content.AudioContentRepository +import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType +import kr.co.vividnext.sodalive.content.main.banner.GetAudioContentBannerResponse +import kr.co.vividnext.sodalive.content.main.curation.GetAudioContentCurationResponse +import kr.co.vividnext.sodalive.content.order.OrderService +import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository +import kr.co.vividnext.sodalive.event.EventItem +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.block.BlockMemberRepository +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service + +@Service +class AudioContentMainService( + private val repository: AudioContentRepository, + private val blockMemberRepository: BlockMemberRepository, + private val orderService: OrderService, + private val audioContentThemeRepository: AudioContentThemeQueryRepository, + + @Value("\${cloud.aws.cloud-front.host}") + private val imageHost: String +) { + fun getMain(member: Member): GetAudioContentMainResponse { + val isAdult = member.auth != null + + // 2주일 이내에 콘텐츠를 올린 크리에이터 20명 조회 + val newContentUploadCreatorList = repository.getNewContentUploadCreatorList( + cloudfrontHost = imageHost, + isAdult = isAdult + ) + .asSequence() + .filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.creatorId) } + .toList() + + val bannerList = repository.getAudioContentMainBannerList(isAdult = isAdult) + .asSequence() + .filter { + if (it.type == AudioContentBannerType.CREATOR && it.creator != null) { + !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.creator!!.id!!) + } else { + true + } + } + .map { + GetAudioContentBannerResponse( + type = it.type, + thumbnailImageUrl = "$imageHost/${it.thumbnailImage}", + eventItem = if (it.type == AudioContentBannerType.EVENT && it.event != null) { + EventItem( + id = it.event!!.id!!, + thumbnailImageUrl = if (!it.event!!.thumbnailImage.startsWith("https://")) { + "$imageHost/${it.event!!.thumbnailImage}" + } else { + it.event!!.thumbnailImage + }, + detailImageUrl = if ( + it.event!!.detailImage != null && + !it.event!!.detailImage!!.startsWith("https://") + ) { + "$imageHost/${it.event!!.detailImage}" + } else { + it.event!!.detailImage + }, + popupImageUrl = null, + link = it.event!!.link, + title = it.event!!.title, + isPopup = false + ) + } else { + null + }, + creatorId = if (it.type == AudioContentBannerType.CREATOR && it.creator != null) { + it.creator!!.id + } else { + null + }, + link = it.link + ) + } + .toList() + + // 구매목록 20개 + val orderList = orderService.getAudioContentMainOrderList( + member = member, + limit = 20 + ) + + // 콘텐츠 테마 + val themeList = audioContentThemeRepository.getActiveThemeOfContent(isAdult = isAdult) + + // 새 콘텐츠 20개 - 시간 내림차순 정렬 + val newContentList = repository.findByTheme( + cloudfrontHost = imageHost, + isAdult = isAdult + ) + .asSequence() + .filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.creatorId) } + .toList() + + val curationList = repository + .getAudioContentCurations(isAdult = isAdult) + .asSequence() + .map { + GetAudioContentCurationResponse( + title = it.title, + description = it.description, + contents = repository.findAudioContentByCurationId( + curationId = it.id!!, + cloudfrontHost = imageHost, + isAdult = isAdult + ) + .asSequence() + .filter { content -> + !blockMemberRepository.isBlocked( + blockedMemberId = member.id!!, + memberId = content.creatorId + ) + } + .toList() + ) + } + .filter { it.contents.isNotEmpty() } + .toList() + + return GetAudioContentMainResponse( + newContentUploadCreatorList = newContentUploadCreatorList, + bannerList = bannerList, + orderList = orderList, + themeList = themeList, + newContentList = newContentList, + curationList = curationList + ) + } + + fun getNewContentByTheme(theme: String, member: Member): List { + return repository.findByTheme( + cloudfrontHost = imageHost, + theme = theme, + isAdult = member.auth != null + ) + .asSequence() + .filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.creatorId) } + .toList() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/GetAudioContentMainItem.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/GetAudioContentMainItem.kt new file mode 100644 index 0000000..1ab49eb --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/GetAudioContentMainItem.kt @@ -0,0 +1,13 @@ +package kr.co.vividnext.sodalive.content.main + +import com.querydsl.core.annotations.QueryProjection + +data class GetAudioContentMainItem @QueryProjection constructor( + val contentId: Long, + val coverImageUrl: String, + val title: String, + val isAdult: Boolean, + val creatorId: Long, + val creatorProfileImageUrl: String, + val creatorNickname: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/GetAudioContentMainResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/GetAudioContentMainResponse.kt new file mode 100644 index 0000000..60b708a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/GetAudioContentMainResponse.kt @@ -0,0 +1,13 @@ +package kr.co.vividnext.sodalive.content.main + +import kr.co.vividnext.sodalive.content.main.banner.GetAudioContentBannerResponse +import kr.co.vividnext.sodalive.content.main.curation.GetAudioContentCurationResponse + +data class GetAudioContentMainResponse( + val newContentUploadCreatorList: List, + val bannerList: List, + val orderList: List, + val themeList: List, + val newContentList: List, + val curationList: List +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/GetNewContentUploadCreator.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/GetNewContentUploadCreator.kt new file mode 100644 index 0000000..55a50a7 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/GetNewContentUploadCreator.kt @@ -0,0 +1,9 @@ +package kr.co.vividnext.sodalive.content.main + +import com.querydsl.core.annotations.QueryProjection + +data class GetNewContentUploadCreator @QueryProjection constructor( + val creatorId: Long, + val creatorNickname: String, + val creatorProfileImageUrl: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBanner.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBanner.kt new file mode 100644 index 0000000..dd71d3b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBanner.kt @@ -0,0 +1,43 @@ +package kr.co.vividnext.sodalive.content.main.banner + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.event.Event +import kr.co.vividnext.sodalive.member.Member +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.OneToOne +import javax.persistence.Table + +@Entity +@Table(name = "content_banner") +data class AudioContentBanner( + @Column(nullable = false) + var thumbnailImage: String = "", + @Enumerated(value = EnumType.STRING) + var type: AudioContentBannerType, + @Column(nullable = false) + var isAdult: Boolean = false, + @Column(nullable = false) + var isActive: Boolean = true, + @Column(nullable = false) + var orders: Int = 1 +) : BaseEntity() { + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "event_id", nullable = true) + var event: Event? = null + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "creator_id", nullable = true) + var creator: Member? = null + + @Column(nullable = true) + var link: String? = null +} + +enum class AudioContentBannerType { + EVENT, CREATOR, LINK +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/GetAudioContentBannerResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/GetAudioContentBannerResponse.kt new file mode 100644 index 0000000..31cc797 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/GetAudioContentBannerResponse.kt @@ -0,0 +1,11 @@ +package kr.co.vividnext.sodalive.content.main.banner + +import kr.co.vividnext.sodalive.event.EventItem + +data class GetAudioContentBannerResponse( + val type: AudioContentBannerType, + val thumbnailImageUrl: String, + val eventItem: EventItem?, + val creatorId: Long?, + val link: String? +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/curation/AudioContentCuration.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/curation/AudioContentCuration.kt new file mode 100644 index 0000000..83ec820 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/curation/AudioContentCuration.kt @@ -0,0 +1,26 @@ +package kr.co.vividnext.sodalive.content.main.curation + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.content.AudioContent +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.OneToMany +import javax.persistence.Table + +@Entity +@Table(name = "content_curation") +data class AudioContentCuration( + @Column(nullable = false) + var title: String, + @Column(nullable = false) + var description: String, + @Column(nullable = false) + var isAdult: Boolean = false, + @Column(nullable = false) + var isActive: Boolean = true, + @Column(nullable = false) + var orders: Int = 1 +) : BaseEntity() { + @OneToMany(mappedBy = "curation") + val audioContents: MutableList = mutableListOf() +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/curation/GetAudioContentCurationResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/curation/GetAudioContentCurationResponse.kt new file mode 100644 index 0000000..802e9e4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/curation/GetAudioContentCurationResponse.kt @@ -0,0 +1,9 @@ +package kr.co.vividnext.sodalive.content.main.curation + +import kr.co.vividnext.sodalive.content.main.GetAudioContentMainItem + +data class GetAudioContentCurationResponse( + val title: String, + val description: String, + val contents: List +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/order/GetAudioContentOrderListResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/GetAudioContentOrderListResponse.kt new file mode 100644 index 0000000..c762fae --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/GetAudioContentOrderListResponse.kt @@ -0,0 +1,20 @@ +package kr.co.vividnext.sodalive.content.order + +import com.querydsl.core.annotations.QueryProjection + +data class GetAudioContentOrderListResponse( + val totalCount: Int, + val items: List +) + +data class GetAudioContentOrderListItem @QueryProjection constructor( + val contentId: Long, + val coverImageUrl: String, + val title: String, + val themeStr: String, + val duration: String?, + val isAdult: Boolean, + val orderType: OrderType, + var likeCount: Int = 0, + var commentCount: Int = 0 +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/order/Order.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/Order.kt new file mode 100644 index 0000000..7b778cd --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/Order.kt @@ -0,0 +1,58 @@ +package kr.co.vividnext.sodalive.content.order + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.member.Member +import java.time.LocalDateTime +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.Table +import kotlin.math.ceil + +enum class OrderType { + RENTAL, KEEP +} + +@Entity +@Table(name = "orders") +data class Order( + @Enumerated(value = EnumType.STRING) + val type: OrderType, + var isActive: Boolean = true +) : BaseEntity() { + var can: Int = 0 + + val startDate: LocalDateTime = LocalDateTime.now() + var endDate: LocalDateTime? = null + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "creator_id", nullable = false) + var creator: Member? = null + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + var member: Member? = null + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id", nullable = false) + var audioContent: AudioContent? = null + set(value) { + can = if (type == OrderType.RENTAL) { + ceil(value?.price!! * 0.7).toInt() + } else { + value?.price!! + } + field = value + } + + override fun prePersist() { + super.prePersist() + if (type == OrderType.RENTAL) { + endDate = startDate.plusDays(7) + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderController.kt new file mode 100644 index 0000000..89c1e08 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderController.kt @@ -0,0 +1,49 @@ +package kr.co.vividnext.sodalive.content.order + +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.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/order") +class OrderController(private val service: OrderService) { + @PostMapping("/audio-content") + fun order( + @RequestBody request: OrderRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok( + service.order( + request.contentId, + request.orderType, + request.container, + member + ) + ) + } + + @GetMapping("/audio-content") + fun getAudioContentOrderList( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + pageable: Pageable + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok( + service.getAudioContentOrderList( + member = member, + offset = pageable.offset, + limit = pageable.pageSize.toLong() + ) + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderRepository.kt new file mode 100644 index 0000000..b76ef6f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderRepository.kt @@ -0,0 +1,215 @@ +package kr.co.vividnext.sodalive.content.order + +import com.querydsl.core.types.dsl.Expressions +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.content.QAudioContent.audioContent +import kr.co.vividnext.sodalive.content.main.GetAudioContentMainItem +import kr.co.vividnext.sodalive.content.main.QGetAudioContentMainItem +import kr.co.vividnext.sodalive.content.order.QOrder.order +import kr.co.vividnext.sodalive.member.QMember.member +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import java.time.Duration +import java.time.LocalDateTime + +@Repository +interface OrderRepository : JpaRepository, OrderQueryRepository + +interface OrderQueryRepository { + fun isExistOrdered(memberId: Long, contentId: Long): Boolean + fun isExistOrderedAndOrderType(memberId: Long, contentId: Long): Pair + fun getAudioContentRemainingTime(memberId: Long, contentId: Long, timezone: String): String + fun getAudioContentOrderList( + dateTime: LocalDateTime, + coverImageHost: String, + memberId: Long, + offset: Long = 0, + limit: Long = 10 + ): List + + fun totalAudioContentOrderListCount(memberId: Long, dateTime: LocalDateTime): Int + fun getAudioContentMainOrderList( + dateTime: LocalDateTime, + coverImageHost: String, + memberId: Long, + offset: Long = 0, + limit: Long = 20 + ): List +} + +@Repository +class OrderQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : OrderQueryRepository { + override fun isExistOrdered(memberId: Long, contentId: Long): Boolean { + return queryFactory + .select(order.id) + .from(order) + .where( + order.member.id.eq(memberId) + .and(order.audioContent.id.eq(contentId)) + .and(order.isActive.isTrue) + .and( + order.type.eq(OrderType.KEEP) + .or( + order.type.eq(OrderType.RENTAL) + .and(order.endDate.after(LocalDateTime.now())) + ) + ) + ) + .fetch() + .isNotEmpty() + } + + override fun isExistOrderedAndOrderType(memberId: Long, contentId: Long): Pair { + val result = queryFactory + .select(order.type) + .from(order) + .where( + order.member.id.eq(memberId) + .and(order.audioContent.id.eq(contentId)) + .and(order.isActive.isTrue) + .and( + order.type.eq(OrderType.KEEP) + .or( + order.type.eq(OrderType.RENTAL) + .and(order.endDate.after(LocalDateTime.now())) + ) + ) + ) + .fetch() + + val isExist = result.isNotEmpty() + + return if (!isExist) { + Pair(false, null) + } else { + if (result.contains(OrderType.KEEP)) { + Pair(true, OrderType.KEEP) + } else { + Pair(true, OrderType.RENTAL) + } + } + } + + override fun getAudioContentRemainingTime(memberId: Long, contentId: Long, timezone: String): String { + val result = queryFactory + .select(order.endDate) + .from(order) + .where( + order.member.id.eq(memberId) + .and(order.audioContent.id.eq(contentId)) + .and(order.isActive.isTrue) + .and( + order.type.eq(OrderType.KEEP) + .or( + order.type.eq(OrderType.RENTAL) + .and(order.endDate.after(LocalDateTime.now())) + ) + ) + ) + .fetchFirst() ?: return "" + + val duration = Duration.between(LocalDateTime.now(), result) + + return duration.toHours().toString().padStart(2, '0') + "시간 " + + duration.toMinutesPart().toString().padStart(2, '0') + "분" + } + + override fun getAudioContentOrderList( + dateTime: LocalDateTime, + coverImageHost: String, + memberId: Long, + offset: Long, + limit: Long + ): List { + return queryFactory + .select( + QGetAudioContentOrderListItem( + audioContent.id, + audioContent.coverImage.prepend("/").prepend(coverImageHost), + audioContent.title, + audioContent.theme.theme, + audioContent.duration, + audioContent.isAdult, + order.type, + Expressions.ZERO, + Expressions.ZERO + ) + ) + .from(order) + .innerJoin(order.audioContent, audioContent) + .where( + order.member.id.eq(memberId) + .and( + order.type.eq(OrderType.KEEP) + .or( + order.type.eq(OrderType.RENTAL) + .and(order.startDate.before(dateTime)) + .and(order.endDate.after(dateTime)) + ) + ) + ) + .offset(offset) + .limit(limit) + .orderBy(order.createdAt.desc()) + .fetch() + } + + override fun totalAudioContentOrderListCount(memberId: Long, dateTime: LocalDateTime): Int { + return queryFactory.select(order.id) + .from(order) + .where( + order.member.id.eq(memberId) + .and( + order.type.eq(OrderType.KEEP) + .or( + order.type.eq(OrderType.RENTAL) + .and(order.startDate.before(dateTime)) + .and(order.endDate.after(dateTime)) + ) + ) + ) + .fetch() + .size + } + + override fun getAudioContentMainOrderList( + dateTime: LocalDateTime, + coverImageHost: String, + memberId: Long, + offset: Long, + limit: Long + ): List { + return queryFactory + .select( + QGetAudioContentMainItem( + audioContent.id, + audioContent.coverImage.prepend("/").prepend(coverImageHost), + audioContent.title, + audioContent.isAdult, + member.id, + member.profileImage.nullif("profile/default-profile.png") + .prepend("/") + .prepend(coverImageHost), + member.nickname + ) + ) + .from(order) + .innerJoin(order.audioContent, audioContent) + .innerJoin(audioContent.member, member) + .where( + order.member.id.eq(memberId) + .and( + order.type.eq(OrderType.KEEP) + .or( + order.type.eq(OrderType.RENTAL) + .and(order.startDate.before(dateTime)) + .and(order.endDate.after(dateTime)) + ) + ) + ) + .offset(offset) + .limit(limit) + .orderBy(order.createdAt.desc()) + .fetch() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderRequest.kt new file mode 100644 index 0000000..9fbc616 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderRequest.kt @@ -0,0 +1,3 @@ +package kr.co.vividnext.sodalive.content.order + +data class OrderRequest(val contentId: Long, val orderType: OrderType, val container: String) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderService.kt new file mode 100644 index 0000000..e16f567 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderService.kt @@ -0,0 +1,99 @@ +package kr.co.vividnext.sodalive.content.order + +import kr.co.vividnext.sodalive.can.payment.CanPaymentService +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.content.AudioContentRepository +import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository +import kr.co.vividnext.sodalive.content.like.AudioContentLikeRepository +import kr.co.vividnext.sodalive.content.main.GetAudioContentMainItem +import kr.co.vividnext.sodalive.member.Member +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +@Transactional(readOnly = true) +class OrderService( + private val repository: OrderRepository, + private val canPaymentService: CanPaymentService, + private val audioContentRepository: AudioContentRepository, + private val audioContentCommentQueryRepository: AudioContentCommentRepository, + private val audioContentLikeQueryRepository: AudioContentLikeRepository, + + @Value("\${cloud.aws.cloud-front.host}") + private val audioContentCoverImageHost: String +) { + @Transactional + fun order(contentId: Long, orderType: OrderType, container: String, member: Member) { + val order = Order(type = orderType) + val content = audioContentRepository.findByIdAndActive(contentId) + ?: throw SodaException("잘못된 콘텐츠 입니다\n다시 시도해 주세요.") + + if (member.id!! == content.member!!.id!!) throw SodaException("자신이 올린 콘텐츠는 구매할 수 없습니다.") + if (repository.isExistOrdered(memberId = member.id!!, contentId = contentId)) { + throw SodaException("이미 구매한 콘텐츠 입니다.") + } + + order.member = member + order.creator = content.member + order.audioContent = content + + repository.save(order) + + canPaymentService.spendCan( + memberId = member.id!!, + needCan = order.can, + canUsage = CanUsage.ORDER_CONTENT, + order = order, + container = container + ) + } + + fun getAudioContentOrderList( + member: Member, + offset: Long, + limit: Long + ): GetAudioContentOrderListResponse { + val totalCount = repository.totalAudioContentOrderListCount( + memberId = member.id!!, + dateTime = LocalDateTime.now() + ) + val orderItems = repository.getAudioContentOrderList( + dateTime = LocalDateTime.now(), + coverImageHost = audioContentCoverImageHost, + memberId = member.id!!, + offset = offset, + limit = limit + ) + .asSequence() + .map { + val commentCount = audioContentCommentQueryRepository + .totalCountCommentByContentId(it.contentId) + + val likeCount = audioContentLikeQueryRepository + .totalCountAudioContentLike(it.contentId) + + it.commentCount = commentCount + it.likeCount = likeCount + it + } + .toList() + + return GetAudioContentOrderListResponse( + totalCount = totalCount, + items = orderItems + ) + } + + fun getAudioContentMainOrderList(member: Member, limit: Int): List { + return repository.getAudioContentMainOrderList( + dateTime = LocalDateTime.now(), + coverImageHost = audioContentCoverImageHost, + memberId = member.id!!, + offset = 0, + limit = 20 + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentTheme.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentTheme.kt new file mode 100644 index 0000000..621b444 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentTheme.kt @@ -0,0 +1,19 @@ +package kr.co.vividnext.sodalive.content.theme + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.Table + +@Entity +@Table(name = "content_theme") +data class AudioContentTheme( + @Column(nullable = false) + var theme: String, + @Column(nullable = false) + var image: String, + @Column(nullable = false) + var isActive: Boolean = true, + @Column(nullable = false) + var orders: Int = 1 +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeController.kt new file mode 100644 index 0000000..7b13530 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeController.kt @@ -0,0 +1,24 @@ +package kr.co.vividnext.sodalive.content.theme + +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.access.prepost.PreAuthorize +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.RestController + +@RestController +@RequestMapping("/audio-content/theme") +@PreAuthorize("hasRole('CREATOR')") +class AudioContentThemeController(private val service: AudioContentThemeService) { + @GetMapping + fun getThemes( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.getThemes()) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeQueryRepository.kt new file mode 100644 index 0000000..e5cba07 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeQueryRepository.kt @@ -0,0 +1,57 @@ +package kr.co.vividnext.sodalive.content.theme + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.content.QAudioContent.audioContent +import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Repository + +@Repository +class AudioContentThemeQueryRepository( + private val queryFactory: JPAQueryFactory, + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) { + fun getActiveThemes(): List { + return queryFactory + .select( + QGetAudioContentThemeResponse( + audioContentTheme.id, + audioContentTheme.theme, + audioContentTheme.image.prepend("/").prepend(cloudFrontHost) + ) + ) + .from(audioContentTheme) + .where(audioContentTheme.isActive.isTrue) + .orderBy(audioContentTheme.orders.asc()) + .fetch() + } + + fun getActiveThemeOfContent(isAdult: Boolean = false): List { + var where = audioContent.isActive.isTrue + .and(audioContentTheme.isActive.isTrue) + + if (!isAdult) { + where = where.and(audioContent.isAdult.isFalse) + } + + return queryFactory + .select(audioContentTheme.theme) + .from(audioContent) + .innerJoin(audioContent.theme, audioContentTheme) + .where(where) + .groupBy(audioContentTheme.id) + .orderBy(audioContentTheme.orders.asc()) + .fetch() + } + + fun findThemeByIdAndActive(id: Long): AudioContentTheme? { + return queryFactory + .selectFrom(audioContentTheme) + .where( + audioContentTheme.id.eq(id) + .and(audioContentTheme.isActive.isTrue) + ) + .fetchFirst() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt new file mode 100644 index 0000000..f8b2fb0 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt @@ -0,0 +1,10 @@ +package kr.co.vividnext.sodalive.content.theme + +import org.springframework.stereotype.Service + +@Service +class AudioContentThemeService(private val queryRepository: AudioContentThemeQueryRepository) { + fun getThemes(): List { + return queryRepository.getActiveThemes() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/GetAudioContentThemeResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/GetAudioContentThemeResponse.kt new file mode 100644 index 0000000..e9e07c5 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/GetAudioContentThemeResponse.kt @@ -0,0 +1,9 @@ +package kr.co.vividnext.sodalive.content.theme + +import com.querydsl.core.annotations.QueryProjection + +data class GetAudioContentThemeResponse @QueryProjection constructor( + val id: Long, + val theme: String, + val image: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/event/Event.kt b/src/main/kotlin/kr/co/vividnext/sodalive/event/Event.kt new file mode 100644 index 0000000..f529a46 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/event/Event.kt @@ -0,0 +1,23 @@ +package kr.co.vividnext.sodalive.event + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Column +import javax.persistence.Entity + +@Entity +data class Event( + @Column(nullable = false) + var thumbnailImage: String, + @Column(nullable = true) + var detailImage: String?, + @Column(nullable = true) + var popupImage: String?, + @Column(nullable = true) + var link: String?, + @Column(nullable = true) + var title: String?, + @Column(nullable = false) + var isPopup: Boolean = false, + @Column(nullable = false) + var isActive: Boolean = true +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/event/EventController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/event/EventController.kt new file mode 100644 index 0000000..17e8bab --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/event/EventController.kt @@ -0,0 +1,56 @@ +package kr.co.vividnext.sodalive.event + +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.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile + +@RestController +@RequestMapping("/event") +class EventController(private val service: EventService) { + @GetMapping + fun getEventList() = ApiResponse.ok(service.getEventList()) + + @GetMapping("/popup") + fun getEventPopup() = ApiResponse.ok(service.getEventPopup()) + + @PostMapping + @PreAuthorize("hasRole('ADMIN')") + fun createEvent( + @RequestParam("thumbnail") thumbnail: MultipartFile, + @RequestParam(value = "detail", required = false) detail: MultipartFile? = null, + @RequestParam(value = "popup", required = false) popup: MultipartFile? = null, + @RequestParam(value = "link", required = false) link: String? = null, + @RequestParam(value = "title", required = false) title: String? = null, + @RequestParam(value = "isPopup") isPopup: Boolean + ) = ApiResponse.ok( + service.save(thumbnail, detail, popup, link, title, isPopup), + "등록되었습니다." + ) + + @PutMapping + @PreAuthorize("hasRole('ADMIN')") + fun updateEvent( + @RequestParam(value = "id") id: Long, + @RequestParam(value = "thumbnail", required = false) thumbnail: MultipartFile? = null, + @RequestParam(value = "detail", required = false) detail: MultipartFile? = null, + @RequestParam(value = "popup", required = false) popup: MultipartFile? = null, + @RequestParam(value = "link", required = false) link: String? = null, + @RequestParam(value = "title", required = false) title: String? = null, + @RequestParam(value = "isPopup", required = false) isPopup: Boolean? = null + ) = ApiResponse.ok( + service.update(id, thumbnail, detail, popup, link, title, isPopup), + "수정되었습니다." + ) + + @DeleteMapping("/{id}") + @PreAuthorize("hasRole('ADMIN')") + fun deleteEvent(@PathVariable id: Long) = ApiResponse.ok(service.delete(id), "삭제되었습니다.") +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/event/EventRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/event/EventRepository.kt new file mode 100644 index 0000000..29624eb --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/event/EventRepository.kt @@ -0,0 +1,59 @@ +package kr.co.vividnext.sodalive.event + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.event.QEvent.event +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface EventRepository : JpaRepository, EventQueryRepository + +interface EventQueryRepository { + fun getEventList(): List + fun getMainEventPopup(): EventItem? +} + +@Repository +class EventQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : EventQueryRepository { + override fun getEventList(): List { + return queryFactory + .select( + QEventItem( + event.id, + event.title, + event.thumbnailImage, + event.detailImage, + event.popupImage, + event.link, + event.isPopup + ) + ) + .from(event) + .where(event.isActive.isTrue) + .orderBy(event.id.desc()) + .fetch() + } + + override fun getMainEventPopup(): EventItem? { + return queryFactory + .select( + QEventItem( + event.id, + event.title, + event.thumbnailImage, + event.detailImage, + event.popupImage, + event.link, + event.isPopup + ) + ) + .from(event) + .where( + event.isActive.isTrue + .and(event.isPopup.isTrue) + .and(event.popupImage.isNotNull) + ) + .orderBy(event.id.desc()) + .fetchFirst() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/event/EventService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/event/EventService.kt new file mode 100644 index 0000000..e6ddd74 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/event/EventService.kt @@ -0,0 +1,205 @@ +package kr.co.vividnext.sodalive.event + +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.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 EventService( + private val repository: EventRepository, + private val s3Uploader: S3Uploader, + + @Value("\${cloud.aws.s3.bucket}") + private val bucket: String, + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) { + fun getEventList(): GetEventResponse { + val eventList = repository.getEventList() + .asSequence() + .map { + if (!it.thumbnailImageUrl.startsWith("https://")) { + it.thumbnailImageUrl = "$cloudFrontHost/${it.thumbnailImageUrl}" + } + + if (it.detailImageUrl != null && !it.detailImageUrl!!.startsWith("https://")) { + it.detailImageUrl = "$cloudFrontHost/${it.detailImageUrl}" + } + + if (it.popupImageUrl != null && !it.popupImageUrl!!.startsWith("https://")) { + it.popupImageUrl = "$cloudFrontHost/${it.popupImageUrl}" + } + + it + } + .toList() + + return GetEventResponse(0, eventList) + } + + fun getEventPopup(): EventItem? { + val eventPopup = repository.getMainEventPopup() + + if (eventPopup != null) { + if (!eventPopup.thumbnailImageUrl.startsWith("https://")) { + eventPopup.thumbnailImageUrl = "$cloudFrontHost/${eventPopup.thumbnailImageUrl}" + } + + if (eventPopup.detailImageUrl != null && !eventPopup.detailImageUrl!!.startsWith("https://")) { + eventPopup.detailImageUrl = "$cloudFrontHost/${eventPopup.detailImageUrl}" + } + + if (eventPopup.popupImageUrl != null && !eventPopup.popupImageUrl!!.startsWith("https://")) { + eventPopup.popupImageUrl = "$cloudFrontHost/${eventPopup.popupImageUrl}" + } + } + + return eventPopup + } + + @Transactional + fun save( + thumbnail: MultipartFile, + detail: MultipartFile? = null, + popup: MultipartFile? = null, + link: String? = null, + title: String? = null, + isPopup: Boolean + ): Long { + if (detail == null && link.isNullOrBlank()) throw SodaException("상세이미지 혹은 링크를 등록하세요") + + val event = repository.save( + Event( + thumbnailImage = "", + detailImage = null, + popupImage = null, + link = link, + title = title, + isPopup = isPopup + ) + ) + + var metadata = ObjectMetadata() + metadata.contentLength = thumbnail.size + val thumbnailImagePath = s3Uploader.upload( + inputStream = thumbnail.inputStream, + bucket = bucket, + filePath = "event/${event.id}/${generateFileName()}", + metadata = metadata + ) + + val detailImagePath = if (detail != null) { + metadata = ObjectMetadata() + metadata.contentLength = detail.size + + s3Uploader.upload( + inputStream = detail.inputStream, + bucket = bucket, + filePath = "event/${event.id}/${generateFileName()}", + metadata = metadata + ) + } else { + null + } + + val popupImagePath = if (popup != null) { + metadata = ObjectMetadata() + metadata.contentLength = popup.size + + s3Uploader.upload( + inputStream = popup.inputStream, + bucket = bucket, + filePath = "event/${event.id}/${generateFileName()}", + metadata = metadata + ) + } else { + null + } + + event.thumbnailImage = thumbnailImagePath + event.detailImage = detailImagePath + event.popupImage = popupImagePath + + return event.id ?: throw SodaException("이벤트 등록을 하지 못했습니다.") + } + + @Transactional + fun update( + id: Long, + thumbnail: MultipartFile? = null, + detail: MultipartFile? = null, + popup: MultipartFile? = null, + link: String? = null, + title: String? = null, + isPopup: Boolean? = null + ) { + if (id <= 0) throw SodaException("잘못된 요청입니다.") + + if (thumbnail == null && detail == null && link.isNullOrBlank() && title.isNullOrBlank()) { + throw SodaException("수정할 내용을 입력하세요.") + } + + val event = repository.findByIdOrNull(id) + ?: throw SodaException("잘못된 요청입니다.") + + if (thumbnail != null) { + val metadata = ObjectMetadata() + metadata.contentLength = thumbnail.size + + event.thumbnailImage = s3Uploader.upload( + inputStream = thumbnail.inputStream, + bucket = bucket, + filePath = "event/${event.id}/${generateFileName()}" + ) + } + + if (detail != null) { + val metadata = ObjectMetadata() + metadata.contentLength = detail.size + + event.detailImage = s3Uploader.upload( + inputStream = detail.inputStream, + bucket = bucket, + filePath = "event/${event.id}/${generateFileName()}" + ) + } + + if (popup != null) { + val metadata = ObjectMetadata() + metadata.contentLength = popup.size + + event.popupImage = s3Uploader.upload( + inputStream = popup.inputStream, + bucket = bucket, + filePath = "event/${event.id}/${generateFileName()}" + ) + } + + if (!link.isNullOrBlank() && event.link != link) { + event.link = link + } + + if (!title.isNullOrBlank() && event.title != title) { + event.title = title + } + + if (isPopup != null) { + event.isPopup = isPopup + } + } + + @Transactional + fun delete(id: Long) { + if (id <= 0) throw SodaException("잘못된 요청입니다.") + val event = repository.findByIdOrNull(id) + ?: throw SodaException("잘못된 요청입니다.") + + event.isActive = false + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/event/GetEventResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/event/GetEventResponse.kt new file mode 100644 index 0000000..5a0b6ee --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/event/GetEventResponse.kt @@ -0,0 +1,18 @@ +package kr.co.vividnext.sodalive.event + +import com.querydsl.core.annotations.QueryProjection + +data class GetEventResponse( + val totalCount: Int, + val eventList: List +) + +data class EventItem @QueryProjection constructor( + val id: Long, + val title: String? = null, + var thumbnailImageUrl: String, + var detailImageUrl: String? = null, + var popupImageUrl: String? = null, + val link: String? = null, + val isPopup: Boolean +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/CreatorResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/CreatorResponse.kt new file mode 100644 index 0000000..c5b8f8b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/CreatorResponse.kt @@ -0,0 +1,15 @@ +package kr.co.vividnext.sodalive.explorer + +data class CreatorResponse( + val creatorId: Long, + val profileUrl: String, + val nickname: String, + val tags: List, + val introduce: String = "", + val instagramUrl: String? = null, + val youtubeUrl: String? = null, + val websiteUrl: String? = null, + val blogUrl: String? = null, + val isNotification: Boolean, + val notificationRecipientCount: Int +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt new file mode 100644 index 0000000..b5d9f7b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt @@ -0,0 +1,89 @@ +package kr.co.vividnext.sodalive.explorer + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.explorer.profile.PostCreatorNoticeRequest +import kr.co.vividnext.sodalive.explorer.profile.PostWriteCheersRequest +import kr.co.vividnext.sodalive.explorer.profile.PutWriteCheersRequest +import kr.co.vividnext.sodalive.member.Member +import org.springframework.data.domain.Pageable +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +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.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/explorer") +class ExplorerController(private val service: ExplorerService) { + @GetMapping + fun getExplorer( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.getExplorer(member)) + } + + @GetMapping("/search/channel") + fun getSearchChannel( + @RequestParam channel: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + ApiResponse.ok(service.getSearchChannel(channel, member)) + } + + @GetMapping("/profile/{id}") + fun getCreatorProfile( + @PathVariable("id") creatorId: Long, + @RequestParam timezone: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + ApiResponse.ok(service.getCreatorProfile(creatorId, timezone, member)) + } + + @PostMapping("/profile/cheers") + fun writeCheers( + @RequestBody request: PostWriteCheersRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + ApiResponse.ok(service.writeCheers(request, member), "등록되었습니다.") + } + + @PutMapping("/profile/cheers") + fun modifyCheers( + @RequestBody request: PutWriteCheersRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + ApiResponse.ok(service.modifyCheers(request, member), "수정되었습니다.") + } + + @PostMapping("/profile/notice") + @PreAuthorize("hasAnyRole('CREATOR')") + fun postCreatorNotice( + @RequestBody request: PostCreatorNoticeRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + ApiResponse.ok(service.saveNotice(member, request.notice), "공지사항이 저장되었습니다.") + } + + @GetMapping("/profile/{id}/follower-list") + fun getFollowerList( + @PathVariable("id") creatorId: Long, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + pageable: Pageable + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + ApiResponse.ok(service.getFollowerList(creatorId, member, pageable)) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt new file mode 100644 index 0000000..db641b4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt @@ -0,0 +1,600 @@ +package kr.co.vividnext.sodalive.explorer + +import com.querydsl.core.types.Predicate +import com.querydsl.core.types.Projections +import com.querydsl.core.types.dsl.Expressions +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.can.use.QUseCan.useCan +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.explorer.follower.GetFollowerListDto +import kr.co.vividnext.sodalive.explorer.follower.QGetFollowerListDto +import kr.co.vividnext.sodalive.explorer.profile.ChannelNotice +import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers +import kr.co.vividnext.sodalive.explorer.profile.QChannelNotice.channelNotice +import kr.co.vividnext.sodalive.explorer.profile.QCreatorCheers.creatorCheers +import kr.co.vividnext.sodalive.explorer.profile.TimeDifferenceResult +import kr.co.vividnext.sodalive.explorer.section.ExplorerSection +import kr.co.vividnext.sodalive.explorer.section.QExplorerSection.explorerSection +import kr.co.vividnext.sodalive.live.room.LiveRoom +import kr.co.vividnext.sodalive.live.room.LiveRoomType +import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom +import kr.co.vividnext.sodalive.live.room.cancel.QLiveRoomCancel.liveRoomCancel +import kr.co.vividnext.sodalive.live.room.visit.QLiveRoomVisit.liveRoomVisit +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.QMember +import kr.co.vividnext.sodalive.member.QMember.member +import kr.co.vividnext.sodalive.member.auth.QAuth.auth +import kr.co.vividnext.sodalive.member.following.QCreatorFollowing.creatorFollowing +import kr.co.vividnext.sodalive.member.tag.QCreatorTag.creatorTag +import kr.co.vividnext.sodalive.member.tag.QMemberCreatorTag.memberCreatorTag +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Repository +import java.time.Duration +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +@Repository +class ExplorerQueryRepository( + private val queryFactory: JPAQueryFactory, + + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) { + fun getNotificationUserIds(creatorId: Long): List { + return queryFactory + .select(creatorFollowing.member.id) + .from(creatorFollowing) + .where( + creatorFollowing.isActive.isTrue + .and(creatorFollowing.creator.id.eq(creatorId)) + ) + .fetch() + } + + fun getMemberDonationRanking( + creatorId: Long, + limit: Long, + offset: Long = 0, + withDonationCan: Boolean + ): List { + val creator = QMember("creator") + val member = QMember("user") + + val donation = useCan.rewardCan.add(useCan.can).sum() + return queryFactory + .select(member, donation) + .from(useCan) + .join(useCan.room, liveRoom) + .join(liveRoom.member, creator) + .join(useCan.member, member) + .offset(offset) + .limit(limit) + .where( + useCan.canUsage.eq(CanUsage.DONATION) + .and(useCan.isRefund.isFalse) + .and(creator.id.eq(creatorId)) + ) + .groupBy(useCan.member.id) + .orderBy(donation.desc(), member.id.desc()) + .fetch() + .map { + val account = it.get(member)!! + val donationCan = it.get(donation)!! + MemberDonationRankingResponse( + account.id!!, + account.nickname, + if (account.profileImage != null) { + "$cloudFrontHost/${account.profileImage}" + } else { + "$cloudFrontHost/profile/default-profile.png" + }, + if (withDonationCan) donationCan else 0 + ) + } + } + + fun getSubscriberGrowthRankingCreators(limit: Long): List { + return queryFactory + .selectFrom(member) + .join(member.follower, creatorFollowing) + .where( + member.role.eq(MemberRole.CREATOR) + .and(creatorFollowing.createdAt.goe(LocalDateTime.now().minusMonths(1))) + .and(creatorFollowing.isActive.isTrue) + ) + .groupBy(member.id) + .orderBy(member.follower.size().desc()) + .limit(limit) + .fetch() + } + + fun getNewCreators(): List { + return queryFactory + .selectFrom(member) + .where( + member.role.eq(MemberRole.CREATOR) + .and(member.createdAt.goe(LocalDateTime.now().minusDays(30))) + ) + .orderBy(Expressions.numberTemplate(Double::class.java, "function('rand')").asc()) + .limit(20) + .fetch() + } + + fun getExplorerSectionData(isAdult: Boolean): List { + var explorerSectionCondition = explorerSection.isActive.eq(true) + if (!isAdult) { + explorerSectionCondition = explorerSectionCondition.and(explorerSection.isAdult.isFalse) + } + + return queryFactory + .selectFrom(explorerSection) + .where(explorerSectionCondition) + .orderBy(explorerSection.orders.asc()) + .fetch() + } + + fun findMemberByTag(tags: List): List { + return queryFactory + .selectFrom(member) + .leftJoin(member.tags, memberCreatorTag) + .join(memberCreatorTag.tag, creatorTag) + .where( + member.role.eq(MemberRole.CREATOR) + .and(creatorTag.tag.`in`(tags)) + ) + .fetch() + .distinct() + } + + fun getSearchChannel(channel: String, memberId: Long): List { + return queryFactory.selectFrom(member) + .where( + member.nickname.containsIgnoreCase(channel) + .and(member.isActive.isTrue) + .and(member.id.ne(memberId)) + .and(member.role.eq(MemberRole.CREATOR)) + ) + .fetch() + } + + fun getAccount(userId: Long): Member? { + return queryFactory + .selectFrom(member) + .where(member.id.eq(userId)) + .fetchFirst() + } + + fun getUserDonationRanking( + creatorId: Long, + limit: Long, + offset: Long = 0, + withDonationCan: Boolean + ): List { + val creatorMember = QMember("creator") + val userMember = QMember("user") + + val donation = useCan.rewardCan.add(useCan.can).sum() + return queryFactory + .select(userMember, donation) + .from(useCan) + .join(useCan.room, liveRoom) + .join(liveRoom.member, creatorMember) + .join(useCan.member, userMember) + .offset(offset) + .limit(limit) + .where( + useCan.canUsage.eq(CanUsage.DONATION) + .and(useCan.isRefund.isFalse) + .and(creatorMember.id.eq(creatorId)) + ) + .groupBy(useCan.member.id) + .orderBy(donation.desc(), userMember.id.desc()) + .fetch() + .map { + val account = it.get(userMember)!! + val donationCan = it.get(donation)!! + UserDonationRankingResponse( + account.id!!, + account.nickname, + if (account.profileImage != null) { + "$cloudFrontHost/${account.profileImage}" + } else { + "$cloudFrontHost/profile/default-profile.png" + }, + if (withDonationCan) donationCan else 0 + ) + } + } + + fun getSimilarCreatorList(creatorId: Long): List { + val creator = queryFactory + .selectFrom(member) + .where(member.id.eq(creatorId)) + .fetchFirst() ?: throw SodaException("없는 사용자 입니다.") + + val creatorTagCount = creator.tags + .asSequence() + .filter { it.tag.isActive } + .map { it.tag } + .toList().size + + if (creatorTagCount <= 0) { + val where = member.role.eq(MemberRole.CREATOR) + .and(member.id.ne(creatorId)) + .and(member.tags.size().gt(0)) + .and(memberCreatorTag.tag.isActive.isTrue) + + return getRandomCreatorList(where = where, limit = 3) + } + + val cnt = memberCreatorTag.member.count() + val similarCreators = queryFactory + .select(member, cnt) + .from(memberCreatorTag) + .innerJoin(memberCreatorTag.member, member) + .innerJoin(memberCreatorTag.tag, creatorTag) + .leftJoin(member.auth, auth) + .where( + member.role.eq(MemberRole.CREATOR) + .and(member.id.ne(creatorId)) + .and( + memberCreatorTag.tag.`in`( + creator.tags + .asSequence() + .filter { it.tag.isActive } + .map { it.tag } + .toList() + ) + ) + ) + .groupBy(memberCreatorTag.member.id) + .orderBy(cnt.desc()) + .limit(3) + .fetch() + .map { + val account = it.get(member)!! + SimilarCreatorResponse( + account.id!!, + account.nickname, + if (account.profileImage != null) { + "$cloudFrontHost/${account.profileImage}" + } else { + "$cloudFrontHost/profile/default-profile.png" + }, + account.tags + .asSequence() + .filter { tag -> tag.tag.isActive } + .map { tag -> tag.tag.tag } + .toList() + ) + } + + if (similarCreators.size < 3) { + val userIds = similarCreators.map { it.userId } + var where = member.role.eq(MemberRole.CREATOR) + .and(member.id.ne(creatorId)) + .and(member.tags.size().gt(0)) + .and(memberCreatorTag.tag.isActive.isTrue) + + for (userId in userIds) { + where = where.and(member.id.ne(userId)) + } + + val additionalCreator = getRandomCreatorList(where = where, limit = (3 - similarCreators.size).toLong()) + return similarCreators + additionalCreator + } else { + return similarCreators + } + } + + private fun getRandomCreatorList(where: Predicate, limit: Long): List { + val result = queryFactory + .select(member) + .from(memberCreatorTag) + .join(memberCreatorTag.member, member) + .innerJoin(memberCreatorTag.tag, creatorTag) + .leftJoin(member.auth, auth) + .groupBy(memberCreatorTag.member.id) + .where(where) + .limit(limit) + .orderBy(Expressions.numberTemplate(Double::class.java, "function('rand')").asc()) + .fetch() + .map { + SimilarCreatorResponse( + it.id!!, + it.nickname, + if (it.profileImage != null) { + "$cloudFrontHost/${it.profileImage}" + } else { + "$cloudFrontHost/profile/default-profile.png" + }, + it.tags + .asSequence() + .filter { tag -> tag.tag.isActive } + .map { tag -> tag.tag.tag } + .toList() + ) + } + return result + } + + fun getLiveRoomList( + creatorId: Long, + userMember: Member, + timezone: String, + limit: Int, + offset: Long = 0 + ): List { + var where = liveRoom.member.id.eq(creatorId) + .and(liveRoom.cancel.id.isNull) + .and(liveRoom.isActive.isTrue) + + if (userMember.auth == null) { + where = where.and(liveRoom.isAdult.isFalse) + } + + val result = mutableListOf() + + if (offset == 0L) { + result.addAll( + queryFactory + .selectFrom(liveRoom) + .innerJoin(liveRoom.member, member) + .leftJoin(liveRoom.cancel, liveRoomCancel) + .where(where) + .orderBy(liveRoom.beginDateTime.asc()) + .fetch() + ) + } + + return result + .map { + val reservations = it.reservations + .filter { reservation -> + reservation.member!!.id!! == userMember.id!! && reservation.isActive + } + + val beginDateTime = it.beginDateTime + .atZone(ZoneId.of("UTC")) + .withZoneSameInstant(ZoneId.of(timezone)) + + val isPaid = if (it.channelName != null) { + val useCan = queryFactory + .selectFrom(useCan) + .where( + useCan.member.id.eq(member.id) + .and(useCan.room.id.eq(it.id!!)) + .and(useCan.canUsage.eq(CanUsage.LIVE)) + ) + .orderBy(useCan.id.desc()) + .fetchFirst() + + useCan != null + } else { + false + } + + LiveRoomResponse( + roomId = it.id!!, + title = it.title, + content = it.notice, + isPaid = isPaid, + beginDateTime = beginDateTime.format( + DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a") + ), + isAdult = it.isAdult, + price = it.price, + channelName = it.channelName, + managerNickname = it.member!!.nickname, + coverImageUrl = if (it.coverImage!!.startsWith("https://")) { + it.coverImage!! + } else { + "$cloudFrontHost/${it.coverImage!!}" + }, + isReservation = reservations.isNotEmpty(), + isActive = it.isActive, + isPrivateRoom = it.type == LiveRoomType.PRIVATE + ) + } + } + + fun getNoticeString(creatorId: Long): String { + return queryFactory + .select(channelNotice.notice) + .from(channelNotice) + .where(channelNotice.member.id.eq(creatorId)) + .fetchFirst() ?: "" + } + + fun getCheersList(creatorId: Long, timezone: String, offset: Long, limit: Long): GetCheersResponse { + val totalCount = queryFactory + .selectFrom(creatorCheers) + .where( + creatorCheers.creator.id.eq(creatorId) + .and(creatorCheers.isActive.isTrue) + .and(creatorCheers.parent.isNull) + ) + .fetch() + .count() + + val cheers = queryFactory + .selectFrom(creatorCheers) + .where( + creatorCheers.creator.id.eq(creatorId) + .and(creatorCheers.isActive.isTrue) + .and(creatorCheers.parent.isNull) + ) + .offset(offset) + .limit(limit) + .orderBy(creatorCheers.id.desc()) + .fetch() + .asSequence() + .map { + val date = it.createdAt!! + .atZone(ZoneId.of("UTC")) + .withZoneSameInstant(ZoneId.of(timezone)) + + GetCheersResponseItem( + cheersId = it.id!!, + nickname = it.member!!.nickname, + profileUrl = if (it.member!!.profileImage != null) { + "$cloudFrontHost/${it.member!!.profileImage}" + } else { + "$cloudFrontHost/profile/default-profile.png" + }, + content = it.cheers, + date = date.format(DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a")), + replyList = it.children.asSequence() + .map { cheers -> + val replyDate = cheers.createdAt!! + .atZone(ZoneId.of("UTC")) + .withZoneSameInstant(ZoneId.of(timezone)) + + GetCheersResponseItem( + cheersId = cheers.id!!, + nickname = cheers.member!!.nickname, + profileUrl = if (cheers.member!!.profileImage != null) { + "$cloudFrontHost/${cheers.member!!.profileImage}" + } else { + "$cloudFrontHost/profile/default-profile.png" + }, + content = cheers.cheers, + date = replyDate.format(DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a")), + replyList = listOf() + ) + } + .toList() + ) + } + .toList() + + return GetCheersResponse( + totalCount = totalCount, + cheers = cheers + ) + } + + fun getLiveCount(creatorId: Long): Long? { + return queryFactory + .select(liveRoom.id.count()) + .from(liveRoom) + .where( + liveRoom.member.id.eq(creatorId) + .and(liveRoom.channelName.isNotNull) + ) + .fetchFirst() + } + + fun getLiveTime(creatorId: Long): Long { + val diffs = queryFactory + .select( + Projections.constructor( + TimeDifferenceResult::class.java, + liveRoom.beginDateTime, + liveRoom.updatedAt + ) + ) + .from(liveRoom) + .where( + liveRoom.member.id.eq(creatorId) + .and(liveRoom.channelName.isNotNull) + ) + .fetch() + + return diffs + .asSequence() + .map { Duration.between(it.beginDateTime, it.updatedAt).toSeconds() } + .sum() / 3600 + } + + fun getLiveContributorCount(creatorId: Long): Long? { + return queryFactory + .select(liveRoomVisit.member.count()) + .from(liveRoomVisit) + .innerJoin(liveRoomVisit.room, liveRoom) + .where( + liveRoom.member.id.eq(creatorId) + .and(liveRoom.channelName.isNotNull) + ) + .fetchFirst() + } + + fun getFollowerListTotalCount(creatorId: Long): Int { + return queryFactory.select(creatorFollowing.id) + .from(creatorFollowing) + .innerJoin(creatorFollowing.member, member) + .where( + member.isActive.isTrue + .and(creatorFollowing.isActive.isTrue) + .and(creatorFollowing.creator.id.eq(creatorId)) + .and(creatorFollowing.member.id.ne(creatorId)) + ) + .fetch() + .size + } + + fun getFollowerList( + creatorId: Long, + offset: Long, + limit: Long + ): List { + return queryFactory + .select( + QGetFollowerListDto( + member.id, + member.profileImage.prepend("/").prepend(cloudFrontHost), + member.nickname, + member.role + ) + ) + .from(creatorFollowing) + .innerJoin(creatorFollowing.member, member) + .where( + member.isActive.isTrue + .and(creatorFollowing.isActive.isTrue) + .and(creatorFollowing.creator.id.eq(creatorId)) + .and(creatorFollowing.member.id.ne(creatorId)) + ) + .offset(offset) + .limit(limit) + .fetch() + } + + fun isFollow(creatorId: Long, memberId: Long): Boolean { + return queryFactory + .select(creatorFollowing.isActive) + .from(creatorFollowing) + .where( + creatorFollowing.creator.id.eq(creatorId) + .and(creatorFollowing.member.id.eq(memberId)) + ) + .fetchOne() ?: false + } + + fun getCreatorCheers(cheersId: Long): CreatorCheers? { + return queryFactory + .selectFrom(creatorCheers) + .where(creatorCheers.id.eq(cheersId)) + .fetchFirst() + } + + fun getCheers(cheersId: Long, memberId: Long): CreatorCheers? { + return queryFactory + .selectFrom(creatorCheers) + .where( + creatorCheers.id.eq(cheersId) + .and(creatorCheers.member.id.eq(memberId)) + ) + .fetchFirst() + } + + fun getNotice(creatorId: Long): ChannelNotice? { + return queryFactory + .selectFrom(channelNotice) + .where(channelNotice.member.id.eq(creatorId)) + .fetchFirst() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt new file mode 100644 index 0000000..a517775 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt @@ -0,0 +1,309 @@ +package kr.co.vividnext.sodalive.explorer + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.explorer.follower.GetFollowerListResponse +import kr.co.vividnext.sodalive.explorer.follower.GetFollowerListResponseItem +import kr.co.vividnext.sodalive.explorer.profile.ChannelNotice +import kr.co.vividnext.sodalive.explorer.profile.ChannelNoticeRepository +import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers +import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository +import kr.co.vividnext.sodalive.explorer.profile.PostWriteCheersRequest +import kr.co.vividnext.sodalive.explorer.profile.PutWriteCheersRequest +import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.MemberService +import org.springframework.beans.factory.annotation.Value +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class ExplorerService( + private val memberService: MemberService, + private val queryRepository: ExplorerQueryRepository, + private val cheersRepository: CreatorCheersRepository, + private val noticeRepository: ChannelNoticeRepository, + + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) { + fun getExplorer(member: Member, growthRankingCreatorsLimit: Long = 20): GetExplorerResponse { + val sections = mutableListOf() + + // 인기 급상승중 (subscriberGrowthRankingCreators) + val growthRankingCreators = queryRepository + .getSubscriberGrowthRankingCreators(limit = growthRankingCreatorsLimit) + .asSequence() + .filter { !memberService.isBlocked(blockedMemberId = member.id!!, memberId = it.id!!) } + .map { + GetExplorerSectionCreatorResponse( + id = it.id!!, + nickname = it.nickname, + tags = it.tags + .asSequence() + .filter { tag -> tag.tag.isActive } + .map { tag -> tag.tag.tag } + .joinToString(" ") { tag -> "#$tag" }, + profileImageUrl = if (it.profileImage != null) { + "$cloudFrontHost/${it.profileImage}" + } else { + "$cloudFrontHost/profile/default-profile.png" + } + ) + } + .toList() + + val growthRankingSection = GetExplorerSectionResponse( + title = "인기 급상승중", + coloredTitle = "인기", + color = "FF5C49", + creators = growthRankingCreators + ) + sections.add(growthRankingSection) + + // 새로 시작 (newCreators) + val newCreators = queryRepository + .getNewCreators() + .asSequence() + .filter { !memberService.isBlocked(blockedMemberId = member.id!!, memberId = it.id!!) } + .map { + GetExplorerSectionCreatorResponse( + id = it.id!!, + nickname = it.nickname, + tags = it.tags + .asSequence() + .filter { tag -> tag.tag.isActive } + .map { tag -> tag.tag.tag } + .joinToString(" ") { tag -> "#$tag" }, + profileImageUrl = if (it.profileImage != null) { + "$cloudFrontHost/${it.profileImage}" + } else { + "$cloudFrontHost/profile/default-profile.png" + } + ) + } + .toList() + + val newCreatorsSection = GetExplorerSectionResponse( + title = "새로 시작", + coloredTitle = "새로", + color = "5FD28F", + creators = newCreators + ) + sections.add(newCreatorsSection) + + // 관리자에서 설정한 타이틀과 크리에이터 + sections.addAll( + queryRepository + .getExplorerSectionData(isAdult = member.auth != null) + .asSequence() + .map { + val tags = it.tags.asSequence().map { explorerSectionTag -> explorerSectionTag.tag!!.tag }.toList() + val creators = queryRepository.findMemberByTag(tags) + .asSequence() + .filter { creator -> + !memberService.isBlocked( + blockedMemberId = member.id!!, + memberId = creator.id!! + ) + } + .toList() + + GetExplorerSectionResponse( + it.title, + it.coloredTitle, + it.color, + creators = creators + .asSequence() + .map { account -> + GetExplorerSectionCreatorResponse( + id = account.id!!, + nickname = account.nickname, + tags = account.tags + .asSequence() + .filter { counselorTag -> counselorTag.tag.isActive } + .toList() + .joinToString(" ") { counselorTag -> + "#${counselorTag.tag.tag}" + }, + profileImageUrl = if (account.profileImage != null) { + "$cloudFrontHost/${account.profileImage}" + } else { + "$cloudFrontHost/profile/default-profile.png" + } + ) + }.toList() + ) + } + .toList() + ) + + return GetExplorerResponse(sections = sections) + } + + fun getSearchChannel(channel: String, member: Member): List { + if (channel.length < 2) { + throw SodaException("두 글자 이상 입력 하셔야 합니다.") + } + + return queryRepository.getSearchChannel(channel, member.id!!) + .asSequence() + .filter { !memberService.isBlocked(blockedMemberId = member.id!!, memberId = it.id!!) } + .map { GetRoomDetailUser(it, cloudFrontHost) } + .toList() + } + + fun getCreatorProfile(creatorId: Long, timezone: String, member: Member): GetCreatorProfileResponse { + // 크리에이터(유저) 정보 + val creatorAccount = queryRepository.getAccount(creatorId) ?: throw SodaException("없는 사용자 입니다.") + + // 차단된 사용자 체크 + val isBlocked = memberService.isBlocked(blockedMemberId = member.id!!, memberId = creatorId) + if (isBlocked) throw SodaException("${creatorAccount.nickname}님의 요청으로 채널 접근이 제한됩니다.") + + val notificationUserIds = queryRepository.getNotificationUserIds(creatorId) + val isNotification = notificationUserIds.contains(member.id) + val notificationRecipientCount = notificationUserIds.size + + // 후원랭킹 + val userDonationRanking = queryRepository.getUserDonationRanking( + creatorId, + 10, + withDonationCan = creatorId == member.id!! + ) + + // 추천 크리에이터 + val similarCreatorList = queryRepository.getSimilarCreatorList(creatorId) + + // 라이브 + val liveRoomList = queryRepository.getLiveRoomList( + creatorId, + userMember = member, + timezone = timezone, + limit = 4 + ) + + // 공지사항 + val notice = queryRepository.getNoticeString(creatorId) + + // 응원 + val cheers = queryRepository.getCheersList(creatorId, timezone = timezone, offset = 0, limit = 4) + + // 차단한 크리에이터 인지 체크 + val isBlock = memberService.isBlocked(blockedMemberId = creatorId, memberId = member.id!!) + + // 활동요약 (라이브 횟수, 라이브 시간, 라이브 참여자, 콘텐츠 수) + val liveCount = queryRepository.getLiveCount(creatorId) ?: 0 + val liveTime = queryRepository.getLiveTime(creatorId) + val liveContributorCount = queryRepository.getLiveContributorCount(creatorId) ?: 0 + val contentCount = 0L + + return GetCreatorProfileResponse( + creator = CreatorResponse( + creatorId = creatorAccount.id!!, + profileUrl = if (creatorAccount.profileImage != null) { + "$cloudFrontHost/${creatorAccount.profileImage}" + } else { + "$cloudFrontHost/profile/default-profile.png" + }, + nickname = creatorAccount.nickname, + tags = creatorAccount.tags.asSequence().filter { it.tag.isActive }.map { it.tag.tag }.toList(), + introduce = creatorAccount.introduce, + instagramUrl = creatorAccount.instagramUrl, + youtubeUrl = creatorAccount.youtubeUrl, + websiteUrl = creatorAccount.websiteUrl, + blogUrl = creatorAccount.blogUrl, + isNotification = isNotification, + notificationRecipientCount = notificationRecipientCount + ), + userDonationRanking = userDonationRanking, + similarCreatorList = similarCreatorList, + liveRoomList = liveRoomList, + notice = notice, + cheers = cheers, + activitySummary = GetCreatorActivitySummary( + liveCount = liveCount, + liveTime = liveTime, + liveContributorCount = liveContributorCount, + contentCount = contentCount + ), + isBlock = isBlock + ) + } + + fun getFollowerList( + creatorId: Long, + member: Member, + pageable: Pageable + ): GetFollowerListResponse { + val totalCount = queryRepository + .getFollowerListTotalCount(creatorId) + + val followerList = queryRepository.getFollowerList(creatorId, pageable.offset, pageable.pageSize.toLong()) + .asSequence() + .map { + val isFollow = if (it.role == MemberRole.CREATOR) { + queryRepository.isFollow(creatorId = it.userId, memberId = member.id!!) + } else { + null + } + + GetFollowerListResponseItem( + userId = it.userId, + profileImage = it.profileImage, + nickname = it.nickname, + isFollow = isFollow + ) + } + .toList() + + return GetFollowerListResponse(totalCount = totalCount, items = followerList) + } + + @Transactional + fun writeCheers(request: PostWriteCheersRequest, member: Member) { + val creator = queryRepository.getAccount(request.creatorId) ?: throw SodaException("없는 사용자 입니다.") + + val isBlocked = memberService.isBlocked(blockedMemberId = member.id!!, memberId = request.creatorId) + if (isBlocked) throw SodaException("${creator.nickname}님의 요청으로 팬토크 작성이 제한됩니다.") + + val cheers = CreatorCheers(cheers = request.content) + cheers.member = member + cheers.creator = creator + + val parent = if (request.parentId != null) { + queryRepository.getCreatorCheers(request.parentId) + } else { + null + } + + if (parent != null) { + cheers.parent = parent + } + + cheersRepository.save(cheers) + } + + @Transactional + fun modifyCheers(request: PutWriteCheersRequest, member: Member) { + val cheers = queryRepository.getCheers(request.cheersId, member.id!!) + ?: throw SodaException("잘못된 요청입니다.") + + cheers.cheers = request.content + } + + @Transactional + fun saveNotice(member: Member, notice: String) { + var channelNotice = queryRepository.getNotice(creatorId = member.id!!) + + if (channelNotice == null) { + channelNotice = ChannelNotice(notice) + channelNotice.member = member + noticeRepository.save(channelNotice) + } else { + channelNotice.notice = notice + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCheersResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCheersResponse.kt new file mode 100644 index 0000000..142f1b2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCheersResponse.kt @@ -0,0 +1,15 @@ +package kr.co.vividnext.sodalive.explorer + +data class GetCheersResponse( + val totalCount: Int, + val cheers: List +) + +data class GetCheersResponseItem( + val cheersId: Long, + val nickname: String, + val profileUrl: String, + val content: String, + val date: String, + val replyList: List +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorProfileResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorProfileResponse.kt new file mode 100644 index 0000000..3a1b5f6 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorProfileResponse.kt @@ -0,0 +1,19 @@ +package kr.co.vividnext.sodalive.explorer + +data class GetCreatorProfileResponse( + val creator: CreatorResponse, + val userDonationRanking: List, + val similarCreatorList: List, + val liveRoomList: List, + val notice: String, + val cheers: GetCheersResponse, + val activitySummary: GetCreatorActivitySummary, + val isBlock: Boolean +) + +data class GetCreatorActivitySummary( + val liveCount: Long, + val liveTime: Long, + val liveContributorCount: Long, + val contentCount: Long +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetExplorerResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetExplorerResponse.kt new file mode 100644 index 0000000..2d7f4e8 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetExplorerResponse.kt @@ -0,0 +1,17 @@ +package kr.co.vividnext.sodalive.explorer + +data class GetExplorerResponse(val sections: List) + +data class GetExplorerSectionResponse( + val title: String, + val coloredTitle: String?, + val color: String?, + val creators: List +) + +data class GetExplorerSectionCreatorResponse( + val id: Long, + val nickname: String, + val tags: String, + val profileImageUrl: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/LiveRoomResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/LiveRoomResponse.kt new file mode 100644 index 0000000..d21bd6b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/LiveRoomResponse.kt @@ -0,0 +1,22 @@ +package kr.co.vividnext.sodalive.explorer + +data class GetLiveRoomAllResponse( + val totalCount: Int, + val liveRoomList: List +) + +data class LiveRoomResponse( + val roomId: Long, + val title: String, + val content: String, + val isPaid: Boolean, + val beginDateTime: String, + val coverImageUrl: String, + val isAdult: Boolean, + val price: Int, + val channelName: String?, + val managerNickname: String, + val isReservation: Boolean, + val isActive: Boolean, + val isPrivateRoom: Boolean +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/MemberDonationRankingResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/MemberDonationRankingResponse.kt new file mode 100644 index 0000000..343c56a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/MemberDonationRankingResponse.kt @@ -0,0 +1,8 @@ +package kr.co.vividnext.sodalive.explorer + +data class MemberDonationRankingResponse( + val userId: Long, + val nickname: String, + val profileImage: String, + val donationCan: Int +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/SimilarCreatorResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/SimilarCreatorResponse.kt new file mode 100644 index 0000000..710baf7 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/SimilarCreatorResponse.kt @@ -0,0 +1,8 @@ +package kr.co.vividnext.sodalive.explorer + +data class SimilarCreatorResponse( + val userId: Long, + val nickname: String, + val profileImage: String, + val tags: List +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/UserDonationRankingResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/UserDonationRankingResponse.kt new file mode 100644 index 0000000..d68947d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/UserDonationRankingResponse.kt @@ -0,0 +1,16 @@ +package kr.co.vividnext.sodalive.explorer + +data class GetDonationAllResponse( + val accumulatedCansToday: Int, + val accumulatedCansLastWeek: Int, + val accumulatedCansThisMonth: Int, + val totalCount: Int, + val userDonationRanking: List +) + +data class UserDonationRankingResponse( + val userId: Long, + val nickname: String, + val profileImage: String, + val donationCan: Int +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/follower/GetFollowerListDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/follower/GetFollowerListDto.kt new file mode 100644 index 0000000..9f16c0e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/follower/GetFollowerListDto.kt @@ -0,0 +1,11 @@ +package kr.co.vividnext.sodalive.explorer.follower + +import com.querydsl.core.annotations.QueryProjection +import kr.co.vividnext.sodalive.member.MemberRole + +data class GetFollowerListDto @QueryProjection constructor( + val userId: Long, + val profileImage: String, + val nickname: String, + val role: MemberRole +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/follower/GetFollowerListResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/follower/GetFollowerListResponse.kt new file mode 100644 index 0000000..d7ef0ec --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/follower/GetFollowerListResponse.kt @@ -0,0 +1,13 @@ +package kr.co.vividnext.sodalive.explorer.follower + +data class GetFollowerListResponse( + val totalCount: Int, + val items: List +) + +data class GetFollowerListResponseItem( + val userId: Long, + val profileImage: String, + val nickname: String, + val isFollow: Boolean? +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/ChannelNotice.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/ChannelNotice.kt new file mode 100644 index 0000000..33993b9 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/ChannelNotice.kt @@ -0,0 +1,19 @@ +package kr.co.vividnext.sodalive.explorer.profile + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.member.Member +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.OneToOne + +@Entity +data class ChannelNotice( + @Column(columnDefinition = "TEXT") + var notice: String +) : BaseEntity() { + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + var member: Member? = null +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/ChannelNoticeRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/ChannelNoticeRepository.kt new file mode 100644 index 0000000..d506bb4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/ChannelNoticeRepository.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.explorer.profile + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface ChannelNoticeRepository : JpaRepository diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorCheers.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorCheers.kt new file mode 100644 index 0000000..56aee2e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorCheers.kt @@ -0,0 +1,36 @@ +package kr.co.vividnext.sodalive.explorer.profile + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.member.Member +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne +import javax.persistence.OneToMany + +@Entity +data class CreatorCheers( + @Column(columnDefinition = "TEXT", nullable = false) + var cheers: String, + val isActive: Boolean = true +) : BaseEntity() { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_id", nullable = true) + var parent: CreatorCheers? = null + set(value) { + value?.children?.add(this) + field = value + } + + @OneToMany(mappedBy = "parent") + var children: MutableList = mutableListOf() + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + var member: Member? = null + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "creator_id", nullable = false) + var creator: Member? = null +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorCheersRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorCheersRepository.kt new file mode 100644 index 0000000..831264a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorCheersRepository.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.explorer.profile + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface CreatorCheersRepository : JpaRepository diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/PostCreatorNoticeRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/PostCreatorNoticeRequest.kt new file mode 100644 index 0000000..c937b9b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/PostCreatorNoticeRequest.kt @@ -0,0 +1,3 @@ +package kr.co.vividnext.sodalive.explorer.profile + +data class PostCreatorNoticeRequest(val notice: String) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/PostWriteCheersRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/PostWriteCheersRequest.kt new file mode 100644 index 0000000..9d75c7f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/PostWriteCheersRequest.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.explorer.profile + +data class PostWriteCheersRequest( + val parentId: Long? = null, + val creatorId: Long, + val content: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/PutWriteCheersRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/PutWriteCheersRequest.kt new file mode 100644 index 0000000..ebb7209 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/PutWriteCheersRequest.kt @@ -0,0 +1,6 @@ +package kr.co.vividnext.sodalive.explorer.profile + +data class PutWriteCheersRequest( + val cheersId: Long, + val content: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/TimeDifferenceResult.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/TimeDifferenceResult.kt new file mode 100644 index 0000000..c1e62f9 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/TimeDifferenceResult.kt @@ -0,0 +1,8 @@ +package kr.co.vividnext.sodalive.explorer.profile + +import java.time.LocalDateTime + +data class TimeDifferenceResult( + val beginDateTime: LocalDateTime, + val updatedAt: LocalDateTime +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/section/ExplorerSection.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/section/ExplorerSection.kt new file mode 100644 index 0000000..503d78f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/section/ExplorerSection.kt @@ -0,0 +1,43 @@ +package kr.co.vividnext.sodalive.explorer.section + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.member.tag.CreatorTag +import javax.persistence.CascadeType +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne +import javax.persistence.OneToMany + +@Entity +data class ExplorerSection( + var title: String, + var isAdult: Boolean, + @Column(nullable = false) + var orders: Int = 1 +) : BaseEntity() { + + @Column(nullable = true) + var coloredTitle: String? = null + + @Column(nullable = true) + var color: String? = null + + @Column(nullable = false) + var isActive: Boolean = true + + @OneToMany(mappedBy = "explorerSection", cascade = [CascadeType.ALL], orphanRemoval = true) + var tags: MutableList = mutableListOf() +} + +@Entity +class ExplorerSectionCreatorTag : BaseEntity() { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "explorer_section_id", nullable = false) + var explorerSection: ExplorerSection? = null + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "creator_tag_id", nullable = false) + var tag: CreatorTag? = null +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/extensions/NumberExtensions.kt b/src/main/kotlin/kr/co/vividnext/sodalive/extensions/NumberExtensions.kt new file mode 100644 index 0000000..3665094 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/extensions/NumberExtensions.kt @@ -0,0 +1,5 @@ +package kr.co.vividnext.sodalive.extensions + +import java.text.DecimalFormat + +fun Int.moneyFormat(): String = DecimalFormat("###,###").format(this) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/extensions/StringExtensions.kt b/src/main/kotlin/kr/co/vividnext/sodalive/extensions/StringExtensions.kt new file mode 100644 index 0000000..ff1bb02 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/extensions/StringExtensions.kt @@ -0,0 +1,9 @@ +package kr.co.vividnext.sodalive.extensions + +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +fun String.convertLocalDateTime(format: String): LocalDateTime { + val dateTimeFormatter = DateTimeFormatter.ofPattern(format) + return LocalDateTime.parse(this, dateTimeFormatter) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/faq/CreateFaqRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/faq/CreateFaqRequest.kt new file mode 100644 index 0000000..1a820c4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/faq/CreateFaqRequest.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.faq + +data class CreateFaqRequest( + val question: String, + val answer: String, + val category: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/faq/Faq.kt b/src/main/kotlin/kr/co/vividnext/sodalive/faq/Faq.kt new file mode 100644 index 0000000..26ce603 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/faq/Faq.kt @@ -0,0 +1,22 @@ +package kr.co.vividnext.sodalive.faq + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne + +@Entity +data class Faq( + @Column(nullable = false) + var question: String, + @Column(columnDefinition = "TEXT", nullable = false) + var answer: String, + var isActive: Boolean = true, + val orders: Int = 1 +) : BaseEntity() { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "category_id", nullable = false) + var category: FaqCategory? = null +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/faq/FaqCategory.kt b/src/main/kotlin/kr/co/vividnext/sodalive/faq/FaqCategory.kt new file mode 100644 index 0000000..b3deac5 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/faq/FaqCategory.kt @@ -0,0 +1,13 @@ +package kr.co.vividnext.sodalive.faq + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Column +import javax.persistence.Entity + +@Entity +data class FaqCategory( + @Column(nullable = false) + val category: String, + val isActive: Boolean = true, + val orders: Int = 1 +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/faq/FaqCategoryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/faq/FaqCategoryRepository.kt new file mode 100644 index 0000000..07e4ee0 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/faq/FaqCategoryRepository.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.faq + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface FaqCategoryRepository : JpaRepository diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/faq/FaqController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/faq/FaqController.kt new file mode 100644 index 0000000..195d3ea --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/faq/FaqController.kt @@ -0,0 +1,41 @@ +package kr.co.vividnext.sodalive.faq + +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.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/faq") +class FaqController(private val service: FaqService) { + @PostMapping + @PreAuthorize("hasRole('ADMIN')") + fun createFaq(@RequestBody request: CreateFaqRequest) = ApiResponse.ok( + service.save(request), + "등록되었습니다." + ) + + @PutMapping + @PreAuthorize("hasRole('ADMIN')") + fun modifyFaq(@RequestBody request: ModifyFaqRequest) = ApiResponse.ok( + service.modify(request), + "수정되었습니다." + ) + + @DeleteMapping("/{id}") + @PreAuthorize("hasRole('ADMIN')") + fun deleteCan(@PathVariable id: Long) = ApiResponse.ok(service.delete(id), "삭제되었습니다.") + + @GetMapping + fun getFaqList(@RequestParam("category") category: String) = ApiResponse.ok(service.getFaqList(category)) + + @GetMapping("/category") + fun getFaqCategoryList() = ApiResponse.ok(service.getCategoryList()) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/faq/FaqRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/faq/FaqRepository.kt new file mode 100644 index 0000000..9ed977d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/faq/FaqRepository.kt @@ -0,0 +1,64 @@ +package kr.co.vividnext.sodalive.faq + +import com.querydsl.core.types.dsl.Expressions +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.faq.QFaq.faq +import kr.co.vividnext.sodalive.faq.QFaqCategory.faqCategory +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface FaqRepository : JpaRepository + +@Repository +class FaqQueryRepository(private val queryFactory: JPAQueryFactory) { + fun getCategory(category: String): FaqCategory? { + return queryFactory + .selectFrom(faqCategory) + .where( + faqCategory.isActive.isTrue + .and(faqCategory.category.eq(category)) + ) + .fetchFirst() + } + + fun getCategoryList(): List { + return queryFactory + .select(faqCategory.category) + .from(faqCategory) + .where(faqCategory.isActive.isTrue) + .orderBy(faqCategory.orders.desc()) + .fetch() + } + + fun getFaqList(category: String): List { + return queryFactory + .select( + QGetFaqResponseItem( + faq.id, + Expressions.asString(category), + faq.question, + faq.answer + ) + ) + .from(faq) + .innerJoin(faq.category, faqCategory) + .where( + faq.isActive.isTrue + .and(faqCategory.isActive.isTrue) + .and(faqCategory.category.eq(category)) + ) + .orderBy(faq.orders.desc()) + .fetch() + } + + fun getFaq(id: Long): Faq? { + return queryFactory + .selectFrom(faq) + .where( + faq.isActive.isTrue + .and(faq.id.eq(id)) + ) + .fetchFirst() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/faq/FaqService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/faq/FaqService.kt new file mode 100644 index 0000000..5912d4a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/faq/FaqService.kt @@ -0,0 +1,62 @@ +package kr.co.vividnext.sodalive.faq + +import kr.co.vividnext.sodalive.common.SodaException +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class FaqService( + private val repository: FaqRepository, + private val queryRepository: FaqQueryRepository +) { + @Transactional + fun save(request: CreateFaqRequest): Long { + if (request.question.isBlank()) throw SodaException("질문을 입력하세요.") + if (request.answer.isBlank()) throw SodaException("답변을 입력하세요.") + if (request.category.isBlank()) throw SodaException("카테고리를 선택하세요.") + + val category = queryRepository.getCategory(request.category) + ?: throw SodaException("잘못된 카테고리 입니다.") + + val faq = Faq(request.question, request.answer) + faq.category = category + + return repository.save(faq).id!! + } + + @Transactional + fun modify(request: ModifyFaqRequest) { + val faq = queryRepository.getFaq(request.id) + ?: throw SodaException("잘못된 요청입니다.") + + if (request.question != null) { + if (request.question.isBlank()) throw SodaException("질문을 입력하세요.") + faq.question = request.question + } + + if (request.answer != null) { + if (request.answer.isBlank()) throw SodaException("답변을 입력하세요.") + faq.answer = request.answer + } + + if (request.category != null) { + if (request.category.isBlank()) throw SodaException("카테고리를 선택하세요.") + val category = queryRepository.getCategory(request.category) ?: throw SodaException("잘못된 카테고리 입니다.") + faq.category = category + } + } + + @Transactional + fun delete(id: Long) { + if (id <= 0) throw SodaException("잘못된 요청입니다.") + val faq = repository.findByIdOrNull(id) + ?: throw SodaException("잘못된 요청입니다.") + + faq.isActive = false + } + + fun getFaqList(category: String) = queryRepository.getFaqList(category) + + fun getCategoryList() = queryRepository.getCategoryList() +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/faq/GetFaqResponseItem.kt b/src/main/kotlin/kr/co/vividnext/sodalive/faq/GetFaqResponseItem.kt new file mode 100644 index 0000000..3227079 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/faq/GetFaqResponseItem.kt @@ -0,0 +1,10 @@ +package kr.co.vividnext.sodalive.faq + +import com.querydsl.core.annotations.QueryProjection + +data class GetFaqResponseItem @QueryProjection constructor( + val id: Long, + val category: String, + val question: String, + val answer: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/faq/ModifyFaqRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/faq/ModifyFaqRequest.kt new file mode 100644 index 0000000..f071507 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/faq/ModifyFaqRequest.kt @@ -0,0 +1,8 @@ +package kr.co.vividnext.sodalive.faq + +data class ModifyFaqRequest( + val id: Long, + val question: String? = null, + val answer: String? = null, + val category: String? = null +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmController.kt new file mode 100644 index 0000000..a35b138 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmController.kt @@ -0,0 +1,47 @@ +package kr.co.vividnext.sodalive.fcm + +import org.springframework.context.ApplicationEventPublisher +import org.springframework.security.access.prepost.PreAuthorize +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("/push") +@PreAuthorize("hasRole('ADMIN')") +class FcmController(private val applicationEventPublisher: ApplicationEventPublisher) { + @PostMapping + fun send( + @RequestBody request: PushRequest + ) = run { + if (request.memberIds.isNotEmpty()) { + applicationEventPublisher.publishEvent( + FcmEvent( + type = FcmEventType.INDIVIDUAL, + title = request.title, + message = request.message, + recipients = request.memberIds + ) + ) + } else { + applicationEventPublisher.publishEvent( + FcmEvent( + type = FcmEventType.ALL, + title = request.title, + message = request.message, + container = "ios" + ) + ) + + applicationEventPublisher.publishEvent( + FcmEvent( + type = FcmEventType.ALL, + title = request.title, + message = request.message, + container = "aos" + ) + ) + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt new file mode 100644 index 0000000..721a8b7 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt @@ -0,0 +1,139 @@ +package kr.co.vividnext.sodalive.fcm + +import kr.co.vividnext.sodalive.member.MemberRepository +import org.springframework.context.event.EventListener +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Component + +enum class FcmEventType { + ALL, INDIVIDUAL, CREATE_LIVE, START_LIVE, UPLOAD_CONTENT, SEND_MESSAGE +} + +class FcmEvent( + val type: FcmEventType, + val title: String, + val message: String, + val container: String = "", + val recipients: List = listOf(), + val isAuth: Boolean = false, + val roomId: Long? = null, + val contentId: Long? = null, + val messageId: Long? = null, + val creatorId: Long? = null +) + +@Component +class FcmSendListener( + private val pushService: FcmService, + private val memberRepository: MemberRepository +) { + @Async + @EventListener + fun send(fcmEvent: FcmEvent) { + when (fcmEvent.type) { + FcmEventType.ALL -> { + if (fcmEvent.container.isNotBlank()) { + val pushTokens = memberRepository.getAllRecipientPushTokens( + fcmEvent.isAuth, + fcmEvent.container + ) + + for (tokens in pushTokens) { + pushService.send( + tokens = tokens, + title = fcmEvent.title, + message = fcmEvent.message, + container = fcmEvent.container + ) + } + } + } + + FcmEventType.INDIVIDUAL -> { + if (fcmEvent.recipients.isNotEmpty()) { + val pushTokens = memberRepository.getIndividualRecipientPushTokens( + recipients = fcmEvent.recipients, + isAuth = fcmEvent.isAuth + ) + + val iosPushTokens = pushTokens["ios"] + val aosPushToken = pushTokens["aos"] + + if (iosPushTokens != null) { + for (tokens in iosPushTokens) { + pushService.send( + tokens = tokens, + title = fcmEvent.title, + message = fcmEvent.message, + container = fcmEvent.container + ) + } + } + + if (aosPushToken != null) { + for (tokens in aosPushToken) { + pushService.send( + tokens = tokens, + title = fcmEvent.title, + message = fcmEvent.message, + container = fcmEvent.container + ) + } + } + } + } + + FcmEventType.CREATE_LIVE, FcmEventType.START_LIVE -> { + if (fcmEvent.container.isNotBlank()) { + val pushTokens = memberRepository.getCreateLiveRoomNotificationRecipientPushTokens( + creatorId = fcmEvent.creatorId!!, + isAuth = fcmEvent.isAuth, + container = fcmEvent.container + ) + + for (tokens in pushTokens) { + pushService.send( + tokens = tokens, + title = fcmEvent.title, + message = fcmEvent.message, + container = fcmEvent.container, + roomId = fcmEvent.roomId + ) + } + } + } + + FcmEventType.UPLOAD_CONTENT -> { + if (fcmEvent.container.isNotBlank()) { + val pushTokens = memberRepository.getUploadContentNotificationRecipientPushTokens( + creatorId = fcmEvent.creatorId!!, + isAuth = fcmEvent.isAuth, + container = fcmEvent.container + ) + + for (tokens in pushTokens) { + pushService.send( + tokens = tokens, + title = fcmEvent.title, + message = fcmEvent.message, + container = fcmEvent.container, + contentId = fcmEvent.contentId + ) + } + } + } + + FcmEventType.SEND_MESSAGE -> { + val response = memberRepository.getMessageRecipientPushToken(messageId = fcmEvent.messageId!!) + + pushService.send( + tokens = listOf(response.pushToken), + title = fcmEvent.title, + message = fcmEvent.message, + container = response.container, + messageId = fcmEvent.messageId + ) + } + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt new file mode 100644 index 0000000..7a678fa --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt @@ -0,0 +1,39 @@ +package kr.co.vividnext.sodalive.fcm + +import com.google.firebase.messaging.FirebaseMessaging +import com.google.firebase.messaging.MulticastMessage +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Service + +@Service +class FcmService { + @Async + fun send( + tokens: List, + title: String, + message: String, + container: String, + roomId: Long? = null, + messageId: Long? = null, + contentId: Long? = null + ) { + val multicastMessage = MulticastMessage.builder() + .putData("title", title) + .putData("message", message) + .addAllTokens(tokens) + + if (roomId != null) { + multicastMessage.putData("room_id", roomId.toString()) + } + + if (messageId != null) { + multicastMessage.putData("message_id", messageId.toString()) + } + + if (contentId != null) { + multicastMessage.putData("content_id", contentId.toString()) + } + + FirebaseMessaging.getInstance().sendEachForMulticast(multicastMessage.build()) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/GetMessageRecipientPushTokenResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/GetMessageRecipientPushTokenResponse.kt new file mode 100644 index 0000000..e20dd2e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/GetMessageRecipientPushTokenResponse.kt @@ -0,0 +1,8 @@ +package kr.co.vividnext.sodalive.fcm + +import com.querydsl.core.annotations.QueryProjection + +data class GetMessageRecipientPushTokenResponse @QueryProjection constructor( + val pushToken: String, + val container: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/PushRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/PushRequest.kt new file mode 100644 index 0000000..c0966b4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/PushRequest.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.fcm + +data class PushRequest( + val memberIds: List, + val title: String, + val message: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/jwt/JwtAccessDeniedHandler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/jwt/JwtAccessDeniedHandler.kt new file mode 100644 index 0000000..d0e9a8b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/jwt/JwtAccessDeniedHandler.kt @@ -0,0 +1,18 @@ +package kr.co.vividnext.sodalive.jwt + +import org.springframework.security.access.AccessDeniedException +import org.springframework.security.web.access.AccessDeniedHandler +import org.springframework.stereotype.Component +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +@Component +class JwtAccessDeniedHandler : AccessDeniedHandler { + override fun handle( + request: HttpServletRequest, + response: HttpServletResponse, + accessDeniedException: AccessDeniedException + ) { + response.sendError(HttpServletResponse.SC_FORBIDDEN) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/jwt/JwtAuthenticationEntryPoint.kt b/src/main/kotlin/kr/co/vividnext/sodalive/jwt/JwtAuthenticationEntryPoint.kt new file mode 100644 index 0000000..72b5ec6 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/jwt/JwtAuthenticationEntryPoint.kt @@ -0,0 +1,18 @@ +package kr.co.vividnext.sodalive.jwt + +import org.springframework.security.core.AuthenticationException +import org.springframework.security.web.AuthenticationEntryPoint +import org.springframework.stereotype.Component +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +@Component +class JwtAuthenticationEntryPoint : AuthenticationEntryPoint { + override fun commence( + request: HttpServletRequest, + response: HttpServletResponse, + authException: AuthenticationException + ) { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/jwt/JwtFilter.kt b/src/main/kotlin/kr/co/vividnext/sodalive/jwt/JwtFilter.kt new file mode 100644 index 0000000..cef7213 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/jwt/JwtFilter.kt @@ -0,0 +1,45 @@ +package kr.co.vividnext.sodalive.jwt + +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.util.StringUtils +import org.springframework.web.filter.OncePerRequestFilter +import javax.servlet.FilterChain +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +class JwtFilter(private val tokenProvider: TokenProvider) : OncePerRequestFilter() { + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + val jwt = resolveToken(request) + val requestURI = request.requestURI + + if (jwt != null && StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) { + val authentication = tokenProvider.getAuthentication(jwt) + SecurityContextHolder.getContext().authentication = authentication + logger.debug("Security Context에 '${authentication.name}' 인증정보를 저장했습니다, uri: $requestURI") + } else { + logger.debug("유효한 JWT 토큰이 없습니다., uri: $requestURI") + if (response.status != 200) { + response.status = 401 + } + } + + filterChain.doFilter(request, response) + } + + private fun resolveToken(request: HttpServletRequest): String? { + val bearerToken = request.getHeader(AUTHORIZATION_HEADER) + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7) + } + + return null + } + + companion object { + const val AUTHORIZATION_HEADER = "Authorization" + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/jwt/TokenProvider.kt b/src/main/kotlin/kr/co/vividnext/sodalive/jwt/TokenProvider.kt new file mode 100644 index 0000000..0ec16ac --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/jwt/TokenProvider.kt @@ -0,0 +1,131 @@ +package kr.co.vividnext.sodalive.jwt + +import io.jsonwebtoken.ExpiredJwtException +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.MalformedJwtException +import io.jsonwebtoken.SignatureAlgorithm +import io.jsonwebtoken.UnsupportedJwtException +import io.jsonwebtoken.io.Decoders +import io.jsonwebtoken.security.Keys +import io.jsonwebtoken.security.SignatureException +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.token.MemberToken +import kr.co.vividnext.sodalive.member.token.MemberTokenRepository +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.InitializingBean +import org.springframework.beans.factory.annotation.Value +import org.springframework.data.repository.findByIdOrNull +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.Authentication +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.stereotype.Component +import java.security.Key +import java.util.Date +import java.util.concurrent.locks.ReentrantReadWriteLock +import kotlin.concurrent.write + +// 토큰의 생성, 유효성 검증 담당 클래스 +@Component +class TokenProvider( + @Value("\${jwt.secret}") + private val secret: String, + @Value("\${jwt.token-validity-in-seconds}") + private val tokenValidityInSeconds: Long, + private val repository: MemberRepository, + private val tokenRepository: MemberTokenRepository +) : InitializingBean { + + private val logger = LoggerFactory.getLogger(TokenProvider::class.java) + private val tokenValidityInMilliseconds: Long = tokenValidityInSeconds * 1000 + private val tokenLocks: MutableMap = mutableMapOf() + + private lateinit var key: Key + + override fun afterPropertiesSet() { + val keyBytes = Decoders.BASE64.decode(secret) + this.key = Keys.hmacShaKeyFor(keyBytes) + } + + fun createToken(authentication: Authentication, memberId: Long): String { + val authorities = authentication.authorities + .joinToString(separator = ",", transform = GrantedAuthority::getAuthority) + + val now = Date().time + val validity = Date(now + tokenValidityInMilliseconds) + + val token = Jwts.builder() + .setSubject(memberId.toString()) + .claim(AUTHORITIES_KEY, authorities) + .signWith(key, SignatureAlgorithm.HS512) + .setExpiration(validity) + .compact() + + val lock = getOrCreateLock(memberId = memberId) + lock.write { + val memberToken = tokenRepository.findByIdOrNull(memberId) + ?: MemberToken(id = memberId) + + memberToken.tokenSet.add(token) + tokenRepository.save(memberToken) + } + + return token + } + + fun getAuthentication(token: String): Authentication { + val claims = Jwts + .parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .body + + val authorities = claims[AUTHORITIES_KEY].toString().split(",").map { SimpleGrantedAuthority(it) } + val memberToken = tokenRepository.findByIdOrNull(id = claims.subject.toLong()) + ?: throw SodaException("로그인 정보를 확인해주세요.") + + if (!memberToken.tokenSet.contains(token)) throw SodaException("로그인 정보를 확인해주세요.") + + val member = repository.findByIdOrNull(id = claims.subject.toLong()) + ?: throw SodaException("로그인 정보를 확인해주세요.") + val principal = MemberAdapter(member) + + return UsernamePasswordAuthenticationToken(principal, token, authorities) + } + + fun validateToken(token: String): Boolean { + try { + Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + + return true + } catch (e: SecurityException) { + logger.info("잘못된 JWT 서명입니다.") + } catch (e: MalformedJwtException) { + logger.info("잘못된 JWT 서명입니다.") + } catch (e: ExpiredJwtException) { + logger.info("만료된 JWT 서명입니다.") + } catch (e: UnsupportedJwtException) { + logger.info("지원되지 않는 JWT 서명입니다.") + } catch (e: IllegalArgumentException) { + logger.info("JWT 토큰이 잘못되었습니다.") + } catch (e: SignatureException) { + logger.info("잘못된 JWT 서명입니다.") + } + + return false + } + + private fun getOrCreateLock(memberId: Long): ReentrantReadWriteLock { + return tokenLocks.computeIfAbsent(memberId) { ReentrantReadWriteLock() } + } + + companion object { + private const val AUTHORITIES_KEY = "auth" + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/GetCreatorFollowingAllListResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/GetCreatorFollowingAllListResponse.kt new file mode 100644 index 0000000..2ae3bc4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/GetCreatorFollowingAllListResponse.kt @@ -0,0 +1,13 @@ +package kr.co.vividnext.sodalive.live.recommend + +data class GetCreatorFollowingAllListResponse( + val totalCount: Int, + val items: List +) + +data class GetCreatorFollowingAllListItem( + val creatorId: Long, + val nickname: String, + val profileImageUrl: String, + val isFollow: Boolean +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/GetRecommendChannelResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/GetRecommendChannelResponse.kt new file mode 100644 index 0000000..587673d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/GetRecommendChannelResponse.kt @@ -0,0 +1,8 @@ +package kr.co.vividnext.sodalive.live.recommend + +data class GetRecommendChannelResponse( + val creatorId: Long, + val nickname: String, + val profileImageUrl: String, + val isOnAir: Boolean +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/GetRecommendLiveResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/GetRecommendLiveResponse.kt new file mode 100644 index 0000000..97f21fe --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/GetRecommendLiveResponse.kt @@ -0,0 +1,6 @@ +package kr.co.vividnext.sodalive.live.recommend + +data class GetRecommendLiveResponse( + val imageUrl: String, + val creatorId: Long +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendController.kt new file mode 100644 index 0000000..a7c005c --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendController.kt @@ -0,0 +1,51 @@ +package kr.co.vividnext.sodalive.live.recommend + +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.RestController + +@RestController +@RequestMapping("/live/recommend") +class LiveRecommendController(private val service: LiveRecommendService) { + @GetMapping + fun getRecommendLive( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.getRecommendLive(member)) + } + + @GetMapping("/channel") + fun getRecommendChannelList( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.getRecommendChannelList(member)) + } + + @GetMapping("/following/channel/list") + fun getFollowingChannelList( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.getFollowingChannelList(member)) + } + + @GetMapping("/following/channel/all/list") + fun getFollowingAllChannelList( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + pageable: Pageable + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.getFollowingAllChannelList(member, pageable)) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepository.kt new file mode 100644 index 0000000..3287f3e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepository.kt @@ -0,0 +1,237 @@ +package kr.co.vividnext.sodalive.live.recommend + +import com.querydsl.core.types.Projections +import com.querydsl.core.types.dsl.Expressions +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.live.recommend.QRecommendLiveCreatorBanner.recommendLiveCreatorBanner +import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.QMember.member +import kr.co.vividnext.sodalive.member.following.QCreatorFollowing.creatorFollowing +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Repository +import java.time.LocalDateTime + +@Repository +class LiveRecommendRepository( + private val queryFactory: JPAQueryFactory, + + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) { + fun getRecommendLive(memberId: Long, isAdult: Boolean): List { + val dateNow = LocalDateTime.now() + + var where = recommendLiveCreatorBanner.startDate.loe(dateNow) + .and(recommendLiveCreatorBanner.endDate.goe(dateNow)) + + if (!isAdult) { + where = where.and(recommendLiveCreatorBanner.isAdult.isFalse) + } + + return queryFactory + .select( + Projections.constructor( + GetRecommendLiveResponse::class.java, + recommendLiveCreatorBanner.image.prepend("/").prepend(cloudFrontHost), + recommendLiveCreatorBanner.creator.id + ) + ) + .from(recommendLiveCreatorBanner) + .where(where) + .orderBy(recommendLiveCreatorBanner.orders.asc()) + .fetch() + } + + fun getOnAirRecommendChannelList( + memberId: Long, + isBlocked: (Long) -> Boolean, + isAdult: Boolean + ): List { + var where = member.role.eq(MemberRole.CREATOR) + .and(member.isActive.isTrue) + + if (!isAdult) { + where = where.and(liveRoom.isAdult.isFalse) + } + + return queryFactory + .select( + Projections.constructor( + GetRecommendChannelResponse::class.java, + member.id, + member.nickname, + member.profileImage.prepend("/").prepend(cloudFrontHost), + Expressions.asBoolean(true) + ) + ) + .from(liveRoom) + .rightJoin(liveRoom.member, member) + .where( + where + .and(liveRoom.isActive.isTrue) + .and(liveRoom.channelName.isNotNull) + .and(liveRoom.channelName.isNotEmpty) + ) + .groupBy(member.id) + .orderBy(Expressions.numberTemplate(Double::class.java, "function('rand')").asc()) + .limit(20) + .fetch() + .asSequence() + .filter { !isBlocked(it.creatorId) } + .toList() + } + + fun getRecommendChannelList( + memberId: Long, + withOutCreatorList: List, + limit: Long, + isBlocked: (Long) -> Boolean + ): List { + val where = member.role.eq(MemberRole.CREATOR) + .and(member.isActive.isTrue) + + return queryFactory + .select( + Projections.constructor( + GetRecommendChannelResponse::class.java, + member.id, + member.nickname, + member.profileImage.prepend("/").prepend(cloudFrontHost), + Expressions.asBoolean(false) + ) + ) + .from(member) + .where(where.and(member.id.notIn(withOutCreatorList))) + .orderBy(Expressions.numberTemplate(Double::class.java, "function('rand')").asc()) + .limit(limit) + .fetch() + } + + fun getOnAirFollowingChannelList( + memberId: Long, + isBlocked: (Long) -> Boolean, + isAdult: Boolean + ): List { + var where = member.role.eq(MemberRole.CREATOR) + .and(member.isActive.isTrue) + + if (!isAdult) { + where = where.and(liveRoom.isAdult.isFalse) + } + + return queryFactory + .select( + Projections.constructor( + GetRecommendChannelResponse::class.java, + member.id, + member.nickname, + member.profileImage.prepend("/").prepend(cloudFrontHost), + Expressions.asBoolean(true) + ) + ) + .from(liveRoom, creatorFollowing) + .rightJoin(liveRoom.member, member) + .innerJoin(creatorFollowing.creator, member) + .where( + where + .and(liveRoom.member.id.eq(creatorFollowing.creator.id)) + .and(creatorFollowing.isActive.isTrue) + .and(creatorFollowing.member.id.eq(memberId)) + .and(liveRoom.isActive.isTrue) + .and(liveRoom.channelName.isNotNull) + .and(liveRoom.channelName.isNotEmpty) + ) + .groupBy(member.id) + .orderBy(Expressions.numberTemplate(Double::class.java, "function('rand')").asc()) + .limit(20) + .fetch() + .asSequence() + .filter { !isBlocked(it.creatorId) } + .toList() + } + + fun getFollowingChannelList( + memberId: Long, + withOutCreatorList: List, + limit: Long, + isBlocked: (Long) -> Boolean + ): List { + val where = member.role.eq(MemberRole.CREATOR) + .and(member.isActive.isTrue) + + return queryFactory + .select( + Projections.constructor( + GetRecommendChannelResponse::class.java, + member.id, + member.nickname, + member.profileImage.prepend("/").prepend(cloudFrontHost), + Expressions.asBoolean(false) + ) + ) + .from(creatorFollowing) + .innerJoin(creatorFollowing.creator, member) + .where( + where + .and(member.id.notIn(withOutCreatorList)) + .and(creatorFollowing.isActive.isTrue) + .and(creatorFollowing.member.id.eq(memberId)) + ) + .orderBy(Expressions.numberTemplate(Double::class.java, "function('rand')").asc()) + .limit(limit) + .fetch() + } + + fun getCreatorFollowingAllListTotalCount(memberId: Long, isBlocked: (Long) -> Boolean): Int { + val where = member.role.eq(MemberRole.CREATOR) + .and(member.isActive.isTrue) + .and(creatorFollowing.isActive.isTrue) + .and(creatorFollowing.member.id.eq(memberId)) + + return queryFactory + .select(creatorFollowing.creator.id) + .from(creatorFollowing) + .innerJoin(creatorFollowing.creator, member) + .where(where) + .groupBy(member.id) + .fetch() + .asSequence() + .filter { !isBlocked(it) } + .toList() + .size + } + + fun getCreatorFollowingAllList( + memberId: Long, + offset: Long, + limit: Long, + isBlocked: (Long) -> Boolean + ): List { + val where = member.role.eq(MemberRole.CREATOR) + .and(member.isActive.isTrue) + .and(creatorFollowing.isActive.isTrue) + .and(creatorFollowing.member.id.eq(memberId)) + + return queryFactory + .select( + Projections.constructor( + GetCreatorFollowingAllListItem::class.java, + member.id, + member.nickname, + member.profileImage.prepend("/").prepend(cloudFrontHost), + Expressions.asBoolean(true) + ) + ) + .from(creatorFollowing) + .innerJoin(creatorFollowing.creator, member) + .where(where) + .groupBy(member.id) + .offset(offset) + .limit(limit) + .fetch() + .asSequence() + .filter { !isBlocked(it.creatorId) } + .toList() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendService.kt new file mode 100644 index 0000000..a842415 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendService.kt @@ -0,0 +1,89 @@ +package kr.co.vividnext.sodalive.live.recommend + +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.block.BlockMemberRepository +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Service + +@Service +class LiveRecommendService( + private val repository: LiveRecommendRepository, + private val blockMemberRepository: BlockMemberRepository +) { + + fun getRecommendLive(member: Member): List { + return repository.getRecommendLive( + memberId = member.id!!, + isAdult = member.auth != null + ) + } + + fun getRecommendChannelList(member: Member): List { + val onAirChannelList = repository.getOnAirRecommendChannelList( + member.id!!, + isBlocked = { blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it) }, + isAdult = member.auth != null + ) + + if (onAirChannelList.size >= 20) { + return onAirChannelList + } + + val onAirCreatorIdList = onAirChannelList.asSequence() + .map { it.creatorId } + .toList() + + val notOnAirCreatorList = repository.getRecommendChannelList( + member.id!!, + withOutCreatorList = onAirCreatorIdList, + limit = (20 - onAirChannelList.size).toLong(), + isBlocked = { blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it) } + ) + + return onAirChannelList + notOnAirCreatorList + } + + fun getFollowingChannelList(member: Member): List { + val onAirFollowingChannelList = repository.getOnAirFollowingChannelList( + memberId = member.id!!, + isBlocked = { blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it) }, + isAdult = member.auth != null + ) + + if (onAirFollowingChannelList.size >= 20) { + return onAirFollowingChannelList + } + + val onAirCreatorIdList = onAirFollowingChannelList.asSequence() + .map { it.creatorId } + .toList() + + val notOnAirFollowingChannelList = repository.getFollowingChannelList( + memberId = member.id!!, + withOutCreatorList = onAirCreatorIdList, + limit = (20 - onAirCreatorIdList.size).toLong(), + isBlocked = { blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it) } + ) + + return onAirFollowingChannelList + notOnAirFollowingChannelList + } + + fun getFollowingAllChannelList(member: Member, pageable: Pageable): GetCreatorFollowingAllListResponse { + val totalCount = repository.getCreatorFollowingAllListTotalCount( + memberId = member.id!!, + isBlocked = { blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it) } + ) + + val items = repository.getCreatorFollowingAllList( + memberId = member.id!!, + offset = pageable.offset, + limit = pageable.pageSize.toLong(), + isBlocked = { blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it) } + ) + + return GetCreatorFollowingAllListResponse( + totalCount = totalCount, + items = items + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/RecommendLiveCreatorBanner.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/RecommendLiveCreatorBanner.kt new file mode 100644 index 0000000..b56d030 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/RecommendLiveCreatorBanner.kt @@ -0,0 +1,28 @@ +package kr.co.vividnext.sodalive.live.recommend + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.member.Member +import java.time.LocalDateTime +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne + +@Entity +data class RecommendLiveCreatorBanner( + @Column(nullable = false) + var startDate: LocalDateTime, + @Column(nullable = false) + var endDate: LocalDateTime, + @Column(nullable = false) + var isAdult: Boolean = false, + @Column(nullable = false) + var orders: Int = 1, + @Column(nullable = true) + var image: String? = null +) : BaseEntity() { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "creator_id", nullable = false) + var creator: Member? = null +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/RecommendLiveCreatorBannerRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/RecommendLiveCreatorBannerRepository.kt new file mode 100644 index 0000000..7c5c605 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/RecommendLiveCreatorBannerRepository.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.live.recommend + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface RecommendLiveCreatorBannerRepository : JpaRepository diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/CancelLiveReservationRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/CancelLiveReservationRequest.kt new file mode 100644 index 0000000..edcf504 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/CancelLiveReservationRequest.kt @@ -0,0 +1,3 @@ +package kr.co.vividnext.sodalive.live.reservation + +data class CancelLiveReservationRequest(val reservationId: Long, val reason: String) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/GetLiveReservationResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/GetLiveReservationResponse.kt new file mode 100644 index 0000000..65627d1 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/GetLiveReservationResponse.kt @@ -0,0 +1,12 @@ +package kr.co.vividnext.sodalive.live.reservation + +data class GetLiveReservationResponse( + val reservationId: Long, + val roomId: Long, + val title: String, + val coverImageUrl: String, + val price: Int, + val masterNickname: String, + val beginDateTime: String, + val cancelable: Boolean +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservation.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservation.kt new file mode 100644 index 0000000..59cf6f2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservation.kt @@ -0,0 +1,27 @@ +package kr.co.vividnext.sodalive.live.reservation + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.live.room.LiveRoom +import kr.co.vividnext.sodalive.member.Member +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne +import javax.persistence.OneToOne + +@Entity +data class LiveReservation( + var isActive: Boolean = true +) : BaseEntity() { + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + var member: Member? = null + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "room_id", nullable = true) + var room: LiveRoom? = null + set(value) { + value?.reservations!!.add(this) + field = value + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationCancel.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationCancel.kt new file mode 100644 index 0000000..1154988 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationCancel.kt @@ -0,0 +1,16 @@ +package kr.co.vividnext.sodalive.live.reservation + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.OneToOne + +@Entity +data class LiveReservationCancel( + val reason: String +) : BaseEntity() { + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reservation_id", nullable = false) + var reservation: LiveReservation? = null +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationCancelRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationCancelRepository.kt new file mode 100644 index 0000000..e5eebe3 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationCancelRepository.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.live.reservation + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface LiveReservationCancelRepository : JpaRepository diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationController.kt new file mode 100644 index 0000000..ead7b72 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationController.kt @@ -0,0 +1,57 @@ +package kr.co.vividnext.sodalive.live.reservation + +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.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.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/live/reservation") +class LiveReservationController(private val service: LiveReservationService) { + @PostMapping + fun makeReservation( + @RequestBody request: MakeLiveReservationRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.makeReservation(request, member.id!!)) + } + + @GetMapping + fun getReservationList( + @RequestParam isActive: Boolean, + @RequestParam(value = "timezone") timezone: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + ApiResponse.ok(service.getReservationList(member.id!!, isActive, timezone)) + } + + @GetMapping("/{id}") + fun getReservation( + @PathVariable id: Long, + @RequestParam(value = "timezone") timezone: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + ApiResponse.ok(service.getReservation(id, member.id!!, timezone)) + } + + @PutMapping("/cancel") + fun cancelReservation( + @RequestBody request: CancelLiveReservationRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + ApiResponse.ok(service.cancelReservation(request, member.id!!)) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationRepository.kt new file mode 100644 index 0000000..07a396f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationRepository.kt @@ -0,0 +1,100 @@ +package kr.co.vividnext.sodalive.live.reservation + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.live.reservation.QLiveReservation.liveReservation +import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.QMember.member +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface LiveReservationRepository : JpaRepository, LiveReservationQueryRepository + +interface LiveReservationQueryRepository { + fun getReservationList(roomId: Long): List + + fun cancelReservation(roomId: Long) + + fun getReservationBookerList(roomId: Long): List + + fun isExistsReservation(roomId: Long, memberId: Long): Boolean + fun getReservationListByMemberId(memberId: Long, active: Boolean): List + + fun getReservationByReservationAndMemberId( + reservationId: Long, + memberId: Long + ): LiveReservation? +} + +@Repository +class LiveReservationQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : LiveReservationQueryRepository { + override fun getReservationList(roomId: Long): List { + return queryFactory + .selectFrom(liveReservation) + .innerJoin(liveReservation.room, liveRoom) + .where( + liveRoom.id.eq(roomId) + .and(liveReservation.isActive.isTrue) + ) + .fetch() + } + + override fun cancelReservation(roomId: Long) { + queryFactory + .update(liveReservation) + .set(liveReservation.isActive, false) + .where(liveReservation.room.id.eq(roomId)) + .execute() + } + + override fun getReservationBookerList(roomId: Long): List { + return queryFactory + .select(member) + .from(liveReservation) + .innerJoin(liveReservation.member, member) + .innerJoin(liveReservation.room, liveRoom) + .where( + liveRoom.id.eq(roomId) + .and(liveReservation.isActive.isTrue) + ) + .fetch() + } + + override fun isExistsReservation(roomId: Long, memberId: Long): Boolean { + return queryFactory + .selectFrom(liveReservation) + .innerJoin(liveReservation.member, member) + .innerJoin(liveReservation.room, liveRoom) + .where( + liveReservation.isActive.isTrue + .and(liveReservation.room.id.eq(roomId)) + .and(liveReservation.member.id.eq(memberId)) + ) + .fetchFirst() != null + } + + override fun getReservationListByMemberId(memberId: Long, active: Boolean): List { + return queryFactory + .selectFrom(liveReservation) + .innerJoin(liveReservation.member, member) + .innerJoin(liveReservation.room, liveRoom) + .where( + liveReservation.isActive.eq(active) + .and(member.id.eq(memberId)) + ) + .fetch() + } + + override fun getReservationByReservationAndMemberId(reservationId: Long, memberId: Long): LiveReservation? { + return queryFactory + .selectFrom(liveReservation) + .innerJoin(liveReservation.member, member) + .innerJoin(liveReservation.room, liveRoom) + .where( + liveReservation.id.eq(reservationId) + .and(member.id.eq(memberId)) + ) + .fetchFirst() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationService.kt new file mode 100644 index 0000000..df2a75d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationService.kt @@ -0,0 +1,181 @@ +package kr.co.vividnext.sodalive.live.reservation + +import kr.co.vividnext.sodalive.can.payment.CanPaymentService +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.live.room.LiveRoomRepository +import kr.co.vividnext.sodalive.live.room.LiveRoomType +import kr.co.vividnext.sodalive.member.MemberRepository +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 java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +@Service +class LiveReservationService( + private val repository: LiveReservationRepository, + private val liveRoomRepository: LiveRoomRepository, + private val memberRepository: MemberRepository, + private val canPaymentService: CanPaymentService, + private val liveReservationCancelRepository: LiveReservationCancelRepository, + + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) { + fun makeReservation(request: MakeLiveReservationRequest, memberId: Long): MakeLiveReservationResponse { + val room = liveRoomRepository.findByIdOrNull(id = request.roomId) + ?: throw SodaException(message = "잘못된 요청입니다.\n다시 시도해 주세요.") + + val member = memberRepository.findByIdOrNull(id = memberId) + ?: throw SodaException(message = "로그인 정보를 확인해주세요.") + + if ( + room.member!!.id!! != memberId && + room.type == LiveRoomType.PRIVATE && + (request.password == null || request.password != room.password) + ) { + throw SodaException("비밀번호가 일치하지 않습니다.\n다시 확인 후 입력해주세요.") + } + + if (repository.isExistsReservation(roomId = request.roomId, memberId = memberId)) { + throw SodaException("이미 예약한 라이브 입니다.") + } + + val haveCan = member.getChargeCan(request.container) + member.getRewardCan(request.container) + if (haveCan < room.price) { + throw SodaException("${room.price - haveCan}캔이 부족합니다. 충전 후 이용해 주세요.") + } + + if (room.price > 0) { + canPaymentService.spendCan( + memberId = member.id!!, + needCan = room.price, + canUsage = CanUsage.LIVE, + liveRoom = room, + container = request.container + ) + } + + val reservation = LiveReservation() + reservation.room = room + reservation.member = member + repository.save(reservation) + + val beginDateTime = room.beginDateTime + .atZone(ZoneId.of("UTC")) + .withZoneSameInstant(ZoneId.of(request.timezone)) + + return MakeLiveReservationResponse( + reservationId = reservation.id!!, + nickname = room.member!!.nickname, + title = room.title, + beginDateString = beginDateTime.format(DateTimeFormatter.ofPattern("yyyy년 M월 d일 (E), a hh:mm")), + price = if (room.price > 0) { + "${room.price} 캔" + } else { + "무료" + }, + haveCan = haveCan, + useCan = room.price, + remainingCan = haveCan - room.price + ) + } + + fun getReservationList(memberId: Long, active: Boolean, timezone: String): List { + return repository + .getReservationListByMemberId(memberId, active) + .asSequence() + .map { + val beginDateTime = it.room!!.beginDateTime + .atZone(ZoneId.of("UTC")) + .withZoneSameInstant(ZoneId.of(timezone)) + + GetLiveReservationResponse( + reservationId = it.id!!, + roomId = it.room!!.id!!, + title = it.room!!.title, + coverImageUrl = if (it.room!!.coverImage != null) { + "$cloudFrontHost/${it.room!!.coverImage}" + } else { + "$cloudFrontHost/profile/default-profile.png" + }, + price = it.room!!.price, + masterNickname = it.room!!.member!!.nickname, + beginDateTime = beginDateTime.format( + DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a") + ), + cancelable = beginDateTime.minusHours(4).isAfter( + LocalDateTime.now() + .atZone(ZoneId.of("UTC")) + .withZoneSameInstant(ZoneId.of(timezone)) + ) + ) + } + .toList() + } + + fun getReservation(reservationId: Long, memberId: Long, timezone: String): GetLiveReservationResponse { + val reservation = repository.getReservationByReservationAndMemberId(reservationId, memberId) + ?: throw SodaException("잘못된 예약정보 입니다.") + + val beginDateTime = reservation.room!!.beginDateTime + .atZone(ZoneId.of("UTC")) + .withZoneSameInstant(ZoneId.of(timezone)) + + return GetLiveReservationResponse( + reservationId = reservation.id!!, + roomId = reservation.room!!.id!!, + title = reservation.room!!.title, + coverImageUrl = if (reservation.room!!.coverImage != null) { + "$cloudFrontHost/${reservation.room!!.coverImage}" + } else { + "$cloudFrontHost/profile/default-profile.png" + }, + price = reservation.room!!.price, + masterNickname = reservation.room!!.member!!.nickname, + beginDateTime = beginDateTime.format( + DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a") + ), + cancelable = beginDateTime.minusHours(4).isAfter( + LocalDateTime.now() + .atZone(ZoneId.of("UTC")) + .withZoneSameInstant(ZoneId.of(timezone)) + ) + ) + } + + @Transactional + fun cancelReservation(request: CancelLiveReservationRequest, memberId: Long) { + if (request.reason.isBlank()) { + throw SodaException("취소사유를 입력하세요.") + } + + val reservation = repository.findByIdOrNull(request.reservationId) + ?: throw SodaException("잘못된 예약정보 입니다.") + + if (reservation.member == null || reservation.member!!.id!! != memberId) { + throw SodaException("잘못된 예약정보 입니다.") + } + + if (reservation.room == null || reservation.room?.id == null) { + throw SodaException("잘못된 예약정보 입니다.") + } + + if (reservation.room!!.beginDateTime.isBefore(LocalDateTime.now().plusHours(4))) { + throw SodaException("라이브 시작 4시간 이내에는 예약취소가 불가능 합니다.") + } + + if (reservation.room!!.price > 0) { + canPaymentService.refund(memberId, roomId = reservation.room!!.id!!) + } + + reservation.isActive = false + + val reservationCancel = LiveReservationCancel(request.reason) + reservationCancel.reservation = reservation + liveReservationCancelRepository.save(reservationCancel) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/MakeLiveReservationRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/MakeLiveReservationRequest.kt new file mode 100644 index 0000000..01d00a8 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/MakeLiveReservationRequest.kt @@ -0,0 +1,8 @@ +package kr.co.vividnext.sodalive.live.reservation + +data class MakeLiveReservationRequest( + val roomId: Long, + val container: String, + val timezone: String = "Asia/Seoul", + val password: String? = null +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/MakeLiveReservationResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/MakeLiveReservationResponse.kt new file mode 100644 index 0000000..3cd4ff0 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/MakeLiveReservationResponse.kt @@ -0,0 +1,12 @@ +package kr.co.vividnext.sodalive.live.reservation + +data class MakeLiveReservationResponse( + val reservationId: Long, + val nickname: String, + val title: String, + val beginDateString: String, + val price: String, + val haveCan: Int, + val useCan: Int, + val remainingCan: Int +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/CreateLiveRoomResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/CreateLiveRoomResponse.kt new file mode 100644 index 0000000..24fb1be --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/CreateLiveRoomResponse.kt @@ -0,0 +1,6 @@ +package kr.co.vividnext.sodalive.live.room + +data class CreateLiveRoomResponse( + val id: Long?, + val channelName: String? +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/CreateSudaRoomRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/CreateSudaRoomRequest.kt new file mode 100644 index 0000000..2df98d8 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/CreateSudaRoomRequest.kt @@ -0,0 +1,15 @@ +package kr.co.vividnext.sodalive.live.room + +data class CreateSudaRoomRequest( + val title: String, + val content: String, + val coverImageUrl: String? = null, + val isAdult: Boolean, + val tags: List, + val numberOfPeople: Int, + val beginDateTimeString: String? = null, + val price: Int = 0, + val timezone: String, + val type: LiveRoomType = LiveRoomType.OPEN, + val password: String? = null +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/EditLiveRoomInfoRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/EditLiveRoomInfoRequest.kt new file mode 100644 index 0000000..437e8f9 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/EditLiveRoomInfoRequest.kt @@ -0,0 +1,9 @@ +package kr.co.vividnext.sodalive.live.room + +data class EditLiveRoomInfoRequest( + val title: String?, + val notice: String?, + val numberOfPeople: Int?, + val beginDateTimeString: String?, + val timezone: String? +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/EnterOrQuitLiveRoomRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/EnterOrQuitLiveRoomRequest.kt new file mode 100644 index 0000000..223c7e8 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/EnterOrQuitLiveRoomRequest.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.live.room + +data class EnterOrQuitLiveRoomRequest( + val roomId: Long, + val container: String, + val password: String? = null +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetLiveRoomUserProfileResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetLiveRoomUserProfileResponse.kt new file mode 100644 index 0000000..42226ab --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetLiveRoomUserProfileResponse.kt @@ -0,0 +1,18 @@ +package kr.co.vividnext.sodalive.live.room + +data class GetLiveRoomUserProfileResponse( + val userId: Long, + val nickname: String, + val profileUrl: String, + val gender: String, + val instagramUrl: String, + val youtubeUrl: String, + val websiteUrl: String, + val blogUrl: String, + val introduce: String, + val tags: String, + val isSpeaker: Boolean?, + val isManager: Boolean?, + val isFollowing: Boolean?, + val isBlock: Boolean +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetRecentRoomInfoResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetRecentRoomInfoResponse.kt new file mode 100644 index 0000000..4a076d2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetRecentRoomInfoResponse.kt @@ -0,0 +1,9 @@ +package kr.co.vividnext.sodalive.live.room + +data class GetRecentRoomInfoResponse( + val title: String, + val notice: String, + var coverImageUrl: String, + val coverImagePath: String, + val numberOfPeople: Int +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetRoomListResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetRoomListResponse.kt new file mode 100644 index 0000000..34edc54 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetRoomListResponse.kt @@ -0,0 +1,19 @@ +package kr.co.vividnext.sodalive.live.room + +data class GetRoomListResponse( + val roomId: Long, + val title: String, + val content: String, + val beginDateTime: String, + val numberOfParticipate: Int, + val numberOfPeople: Int, + val coverImageUrl: String, + val isAdult: Boolean, + val price: Int, + val tags: List, + val channelName: String?, + val creatorNickname: String, + val creatorId: Long, + val isReservation: Boolean, + val isPrivateRoom: Boolean +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoom.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoom.kt new file mode 100644 index 0000000..3c95441 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoom.kt @@ -0,0 +1,68 @@ +package kr.co.vividnext.sodalive.live.room + +import kr.co.vividnext.sodalive.can.use.UseCan +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.live.reservation.LiveReservation +import kr.co.vividnext.sodalive.live.room.cancel.LiveRoomCancel +import kr.co.vividnext.sodalive.live.room.visit.LiveRoomVisit +import kr.co.vividnext.sodalive.member.Member +import java.time.LocalDateTime +import javax.persistence.CascadeType +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.OneToMany +import javax.persistence.OneToOne + +@Entity +data class LiveRoom( + var title: String, + @Column(columnDefinition = "TEXT", nullable = false) + var notice: String, + var beginDateTime: LocalDateTime, + var numberOfPeople: Int, + var coverImage: String? = null, + var bgImage: String? = null, + var isAdult: Boolean, + val price: Int = 0, + @Enumerated(value = EnumType.STRING) + val type: LiveRoomType = LiveRoomType.OPEN, + @Column(nullable = true) + var password: String? = null +) : BaseEntity() { + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + var member: Member? = null + + @OneToMany(mappedBy = "room", cascade = [CascadeType.ALL]) + var tags: MutableList = mutableListOf() + + @OneToMany(mappedBy = "room", fetch = FetchType.LAZY) + var useCan: MutableList = mutableListOf() + + @OneToMany(mappedBy = "room", fetch = FetchType.LAZY) + var reservations: MutableList = mutableListOf() + + @OneToMany(mappedBy = "room", fetch = FetchType.LAZY) + var visits: MutableList = mutableListOf() + + @OneToOne(mappedBy = "room") + var cancel: LiveRoomCancel? = null + var channelName: String? = null + var isActive: Boolean = true +} + +enum class LiveRoomType { + // 공개 + OPEN, + + // 비공개 + PRIVATE +} + +enum class LiveRoomStatus { + NOW, RESERVATION +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomController.kt new file mode 100644 index 0000000..6d8daac --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomController.kt @@ -0,0 +1,245 @@ +package kr.co.vividnext.sodalive.live.room + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.live.room.cancel.CancelLiveRequest +import kr.co.vividnext.sodalive.live.room.donation.DeleteLiveRoomDonationMessage +import kr.co.vividnext.sodalive.live.room.donation.LiveRoomDonationRequest +import kr.co.vividnext.sodalive.live.room.visit.LiveRoomVisitService +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.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.RequestParam +import org.springframework.web.bind.annotation.RequestPart +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile + +@RestController +@RequestMapping("/live/room") +class LiveRoomController( + private val service: LiveRoomService, + private val visitService: LiveRoomVisitService +) { + + @GetMapping + fun getRoomList( + @RequestParam timezone: String, + @RequestParam dateString: String? = null, + @RequestParam status: LiveRoomStatus, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + pageable: Pageable + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.getRoomList(dateString, status, pageable, member, timezone)) + } + + @PostMapping + fun createLiveRoom( + @RequestPart("coverImage") coverImage: MultipartFile?, + @RequestPart("request") requestString: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.createLiveRoom(coverImage, requestString, member)) + } + + @GetMapping("/detail/{id}") + fun getRoomDetail( + @PathVariable id: Long, + @RequestParam timezone: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.getRoomDetail(id, member, timezone)) + } + + @PostMapping("/enter") + fun enterLive( + @RequestBody request: EnterOrQuitLiveRoomRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.enterLive(request, member)) + } + + @PutMapping("/start") + fun startLive( + @RequestBody request: StartLiveRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.startLive(request, member)) + } + + @PutMapping("/cancel") + fun cancelLive( + @RequestBody request: CancelLiveRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.cancelLive(request, member)) + } + + @GetMapping("/recent-room-info") + fun getRecentRoomInfo( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.getRecentRoomInfo(member)) + } + + @PutMapping("/{id}") + fun editLiveRoomInfo( + @PathVariable("id") roomId: Long, + @RequestPart("coverImage") coverImage: MultipartFile?, + @RequestPart("request") requestString: String?, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.editLiveRoomInfo(roomId, coverImage, requestString, member)) + } + + @GetMapping("/info/{id}") + fun getRoomInfo( + @PathVariable id: Long, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.getRoomInfo(roomId = id, member)) + } + + @GetMapping("/donation-message") + fun getDonationMessageList( + @RequestParam roomId: Long, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.getDonationMessageList(roomId, member)) + } + + @DeleteMapping("/donation-message") + fun removeDonationMessage( + @RequestBody request: DeleteLiveRoomDonationMessage, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.deleteDonationMessage(request, member)) + } + + @GetMapping("/{room_id}/profile/{user_id}") + fun getUserProfile( + @PathVariable("room_id") roomId: Long, + @PathVariable("user_id") userId: Long, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.getUserProfile(roomId, userId, member)) + } + + @GetMapping("/{id}/donation-total") + fun donationTotal( + @PathVariable("id") roomId: Long, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.getDonationTotal(roomId)) + } + + @PutMapping("/info/set/speaker") + fun setSpeaker( + @RequestBody request: SetManagerOrSpeakerOrAudienceRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.setSpeaker(request)) + } + + @PutMapping("/info/set/listener") + fun setListener( + @RequestBody request: SetManagerOrSpeakerOrAudienceRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.setListener(request)) + } + + @PutMapping("/info/set/manager") + fun setManager( + @RequestBody request: SetManagerOrSpeakerOrAudienceRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.setManager(request, member)) + } + + @PostMapping("/donation") + fun donation( + @RequestBody request: LiveRoomDonationRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.donation(request, member)) + } + + @PostMapping("/donation/refund/{id}") + fun refundDonation( + @PathVariable id: Long, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.refundDonation(id, member)) + } + + @GetMapping("/{id}/donation-list") + fun donationList( + @PathVariable("id") roomId: Long, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.getDonationStatus(roomId, member)) + } + + @PostMapping("/quit") + fun quitRoom( + @RequestParam("id") roomId: Long, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + ApiResponse.ok(service.quitRoom(roomId, member)) + } + + @GetMapping("/recent_visit_room/users") + fun recentVisitRoomUsers( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(visitService.getRecentVisitRoomUsers(member.id!!)) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomRepository.kt new file mode 100644 index 0000000..df04a37 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomRepository.kt @@ -0,0 +1,213 @@ +package kr.co.vividnext.sodalive.live.room + +import com.querydsl.core.types.OrderSpecifier +import com.querydsl.core.types.Predicate +import com.querydsl.core.types.Projections +import com.querydsl.core.types.dsl.CaseBuilder +import com.querydsl.core.types.dsl.Expressions +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.can.use.QUseCan.useCan +import kr.co.vividnext.sodalive.can.use.QUseCanCalculate.useCanCalculate +import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom +import kr.co.vividnext.sodalive.live.room.donation.GetLiveRoomDonationItem +import kr.co.vividnext.sodalive.live.room.donation.QGetLiveRoomDonationItem +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 +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +@Repository +interface LiveRoomRepository : JpaRepository, LiveRoomQueryRepository + +interface LiveRoomQueryRepository { + fun getLiveRoomList( + dateString: String?, + status: LiveRoomStatus, + pageable: Pageable, + member: Member, + timezone: String, + isAdult: Boolean + ): List + + fun getLiveRoom(id: Long): LiveRoom? + fun getLiveRoomAndAccountId(roomId: Long, memberId: Long): LiveRoom? + fun getRecentRoomInfo(memberId: Long, cloudFrontHost: String): GetRecentRoomInfoResponse? + fun getDonationTotal(roomId: Long): Int? + fun getDonationList(roomId: Long, cloudFrontHost: String): List +} + +class LiveRoomQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : LiveRoomQueryRepository { + override fun getLiveRoomList( + dateString: String?, + status: LiveRoomStatus, + pageable: Pageable, + member: Member, + timezone: String, + isAdult: Boolean + ): List { + var where: Predicate + + if (status == LiveRoomStatus.NOW) { + where = liveRoom.channelName.isNotNull + .and(liveRoom.channelName.isNotEmpty) + } else { + where = if (dateString != null) { + val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + val date = LocalDate.parse(dateString, dateTimeFormatter).atStartOfDay() + .atZone(ZoneId.of(timezone)) + .withZoneSameInstant(ZoneId.of("UTC")) + .toLocalDateTime() + + liveRoom.beginDateTime.goe(date) + .and(liveRoom.beginDateTime.lt(date.plusDays(1))) + .and( + liveRoom.channelName.isNull + .or(liveRoom.channelName.isEmpty) + ) + } else { + liveRoom.beginDateTime.gt( + LocalDateTime.now() + .atZone(ZoneId.of(timezone)) + .withZoneSameInstant(ZoneId.of("UTC")) + .toLocalDateTime() + ) + .and( + liveRoom.channelName.isNull + .or(liveRoom.channelName.isEmpty) + ) + } + } + + if (!isAdult) { + where = where.and(liveRoom.isAdult.isFalse) + } + + where = where.and(liveRoom.isActive.isTrue) + .and(liveRoom.member.isNotNull) + + return queryFactory + .selectFrom(liveRoom) + .innerJoin(liveRoom.member, QMember.member) + .offset(pageable.offset) + .limit(pageable.pageSize.toLong()) + .where(where) + .orderBy( + *orderByFieldAccountId( + memberId = member.id!!, + status = status, + offset = pageable.offset, + dateString = dateString + ) + ) + .fetch() + } + + override fun getLiveRoom(id: Long): LiveRoom? { + return queryFactory + .selectFrom(liveRoom) + .innerJoin(liveRoom.member, member) + .where( + liveRoom.id.eq(id) + .and(liveRoom.isActive.isTrue) + ) + .fetchFirst() + } + + override fun getLiveRoomAndAccountId(roomId: Long, memberId: Long): LiveRoom? { + return queryFactory + .selectFrom(liveRoom) + .innerJoin(liveRoom.member, member) + .where( + liveRoom.id.eq(roomId) + .and(liveRoom.isActive.isTrue) + .and(member.id.eq(memberId)) + ) + .fetchFirst() + } + + override fun getRecentRoomInfo(memberId: Long, cloudFrontHost: String): GetRecentRoomInfoResponse? { + return queryFactory + .select( + Projections.constructor( + GetRecentRoomInfoResponse::class.java, + liveRoom.title, + liveRoom.notice, + liveRoom.coverImage.prepend("/").prepend(cloudFrontHost), + liveRoom.coverImage, + liveRoom.numberOfPeople + ) + ) + .from(liveRoom) + .where(liveRoom.member.id.eq(memberId)) + .orderBy(liveRoom.id.desc()) + .limit(1) + .fetchFirst() + } + + override fun getDonationTotal(roomId: Long): Int? { + return queryFactory + .select(useCanCalculate.can.sum()) + .from(useCanCalculate) + .innerJoin(useCanCalculate.useCan, useCan) + .innerJoin(useCan.room, liveRoom) + .where( + liveRoom.id.eq(roomId) + .and(useCan.canUsage.eq(CanUsage.DONATION)) + .and(useCan.isRefund.isFalse) + ) + .fetchOne() + } + + override fun getDonationList(roomId: Long, cloudFrontHost: String): List { + return queryFactory + .select( + QGetLiveRoomDonationItem( + member.profileImage + .coalesce("profile/default-profile.png") + .prepend("/") + .prepend(cloudFrontHost), + member.nickname, + member.id.coalesce(0), + useCan.can.sum().add(useCan.rewardCan.sum()) + ) + ) + .from(useCan) + .join(useCan.member, member) + .groupBy(useCan.member) + .where( + useCan.room.id.eq(roomId) + .and(useCan.canUsage.eq(CanUsage.DONATION)) + .and(useCan.isRefund.isFalse) + ) + .orderBy(useCan.can.sum().add(useCan.rewardCan.sum()).desc()) + .fetch() + } + + private fun orderByFieldAccountId( + memberId: Long, + status: LiveRoomStatus, + offset: Long, + dateString: String? + ): Array> { + return if (status == LiveRoomStatus.NOW) { + arrayOf(Expressions.numberTemplate(Double::class.java, "function('rand')").asc()) + } else if (status == LiveRoomStatus.RESERVATION && offset == 0L && dateString == null) { + arrayOf( + CaseBuilder() + .`when`(member.id.eq(memberId)).then(1) + .otherwise(2) + .asc(), + liveRoom.beginDateTime.asc() + ) + } else { + arrayOf(liveRoom.beginDateTime.asc()) + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt new file mode 100644 index 0000000..6f04a46 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt @@ -0,0 +1,966 @@ +package kr.co.vividnext.sodalive.live.room + +import com.amazonaws.services.s3.model.ObjectMetadata +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.agora.RtcTokenBuilder +import kr.co.vividnext.sodalive.agora.RtmTokenBuilder +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +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.payment.CanPaymentService +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.can.use.CanUsage +import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository +import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository +import kr.co.vividnext.sodalive.extensions.convertLocalDateTime +import kr.co.vividnext.sodalive.fcm.FcmEvent +import kr.co.vividnext.sodalive.fcm.FcmEventType +import kr.co.vividnext.sodalive.live.reservation.LiveReservationRepository +import kr.co.vividnext.sodalive.live.room.cancel.CancelLiveRequest +import kr.co.vividnext.sodalive.live.room.cancel.LiveRoomCancel +import kr.co.vividnext.sodalive.live.room.cancel.LiveRoomCancelRepository +import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailManager +import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailResponse +import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser +import kr.co.vividnext.sodalive.live.room.donation.DeleteLiveRoomDonationMessage +import kr.co.vividnext.sodalive.live.room.donation.GetLiveRoomDonationStatusResponse +import kr.co.vividnext.sodalive.live.room.donation.GetLiveRoomDonationTotalResponse +import kr.co.vividnext.sodalive.live.room.donation.LiveRoomDonationMessage +import kr.co.vividnext.sodalive.live.room.donation.LiveRoomDonationRequest +import kr.co.vividnext.sodalive.live.room.info.GetRoomInfoResponse +import kr.co.vividnext.sodalive.live.room.info.LiveRoomInfo +import kr.co.vividnext.sodalive.live.room.info.LiveRoomInfoRedisRepository +import kr.co.vividnext.sodalive.live.room.info.LiveRoomMember +import kr.co.vividnext.sodalive.live.room.kickout.LiveRoomKickOutService +import kr.co.vividnext.sodalive.live.room.visit.LiveRoomVisitService +import kr.co.vividnext.sodalive.live.tag.LiveTagRepository +import kr.co.vividnext.sodalive.member.Gender +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.block.BlockMemberRepository +import kr.co.vividnext.sodalive.utils.generateFileName +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.ApplicationEventPublisher +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 +import java.util.Date +import java.util.concurrent.locks.ReentrantReadWriteLock +import kotlin.concurrent.write + +@Service +@Transactional(readOnly = true) +class LiveRoomService( + private val repository: LiveRoomRepository, + private val roomInfoRepository: LiveRoomInfoRedisRepository, + private val roomCancelRepository: LiveRoomCancelRepository, + private val kickOutService: LiveRoomKickOutService, + private val blockMemberRepository: BlockMemberRepository, + + private val applicationEventPublisher: ApplicationEventPublisher, + private val useCanCalculateRepository: UseCanCalculateRepository, + private val reservationRepository: LiveReservationRepository, + private val explorerQueryRepository: ExplorerQueryRepository, + private val roomVisitService: LiveRoomVisitService, + private val canPaymentService: CanPaymentService, + private val chargeRepository: ChargeRepository, + private val memberRepository: MemberRepository, + private val tagRepository: LiveTagRepository, + private val canRepository: CanRepository, + private val objectMapper: ObjectMapper, + private val s3Uploader: S3Uploader, + + private val rtcTokenBuilder: RtcTokenBuilder, + private val rtmTokenBuilder: RtmTokenBuilder, + + @Value("\${agora.app-id}") + private val agoraAppId: String, + + @Value("\${agora.app-certificate}") + private val agoraAppCertificate: String, + + @Value("\${cloud.aws.s3.bucket}") + private val coverImageBucket: String, + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) { + private val tokenLocks: MutableMap = mutableMapOf() + + fun getRoomList( + dateString: String?, + status: LiveRoomStatus, + pageable: Pageable, + member: Member, + timezone: String + ): List { + return repository + .getLiveRoomList( + dateString = dateString, + status = status, + pageable = pageable, + member = member, + timezone = timezone, + isAdult = member.auth != null + ) + .asSequence() + .filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) } + .map { + val roomInfo = roomInfoRepository.findByIdOrNull(it.id!!) + + val reservations = it.reservations + .filter { reservation -> reservation.member!!.id!! == member.id!! && reservation.isActive } + + val beginDateTime = it.beginDateTime + .atZone(ZoneId.of("UTC")) + .withZoneSameInstant(ZoneId.of(timezone)) + + GetRoomListResponse( + roomId = it.id!!, + title = it.title, + content = it.notice, + beginDateTime = beginDateTime.format( + DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a") + ), + numberOfParticipate = (roomInfo?.listenerCount ?: 0) + + (roomInfo?.speakerCount ?: 0) + + (roomInfo?.managerCount ?: 0), + numberOfPeople = it.numberOfPeople, + isAdult = it.isAdult, + price = it.price, + channelName = it.channelName, + creatorNickname = it.member!!.nickname, + creatorId = it.member!!.id!!, + tags = it.tags + .asSequence() + .filter { tag -> tag.tag.isActive } + .map { tag -> tag.tag.tag } + .toList(), + coverImageUrl = if (it.coverImage!!.startsWith("https://")) { + it.coverImage!! + } else { + "$cloudFrontHost/${it.coverImage!!}" + }, + isReservation = reservations.isNotEmpty(), + isPrivateRoom = it.type == LiveRoomType.PRIVATE + ) + } + .toList() + } + + @Transactional + fun createLiveRoom(coverImage: MultipartFile?, requestString: String, member: Member): CreateLiveRoomResponse { + val request = objectMapper.readValue(requestString, CreateSudaRoomRequest::class.java) + if (request.coverImageUrl == null && coverImage == null) { + throw SodaException("커버이미지를 선택해 주세요.") + } + + val now = LocalDateTime.now() + val beginDateTime = if (request.beginDateTimeString != null) { + request.beginDateTimeString.convertLocalDateTime("yyyy-MM-dd HH:mm") + .atZone(ZoneId.of(request.timezone)) + .withZoneSameInstant(ZoneId.of("UTC")) + .toLocalDateTime() + } else { + now + } + + if ( + request.beginDateTimeString != null && + beginDateTime < now.plusMinutes(30) + ) { + throw SodaException("현재시각 기준, 30분 이후부터 설정가능합니다.") + } + + if ( + request.type == LiveRoomType.PRIVATE && + (request.password == null || request.password.length != 6) + ) { + throw SodaException("방 입장 비밀번호 6자리를 입력해 주세요.") + } + + val room = LiveRoom( + title = request.title, + notice = request.content, + beginDateTime = beginDateTime, + numberOfPeople = request.numberOfPeople, + isAdult = request.isAdult, + price = request.price, + type = request.type, + password = request.password + ) + room.member = member + + if (request.beginDateTimeString == null) { + room.channelName = "SODA_LIVE_CHANNEL_" + + "${member.id!!}_${beginDateTime.year}_${beginDateTime.month}_${beginDateTime.dayOfMonth}_" + + "${beginDateTime.hour}_${beginDateTime.minute}" + } + + request.tags.forEach { + val tag = tagRepository.findByTag(it) + if (tag != null) { + room.tags.add(LiveRoomTag(room, tag)) + + if (tag.tag.contains("음담패설")) { + room.isAdult = true + } + } + } + + val createdRoom = repository.save(room) + // 이미지 업로드 + if (coverImage != null) { + val metadata = ObjectMetadata() + metadata.contentLength = coverImage.size + + // 커버 이미지 파일명 생성 + val coverImageFileName = generateFileName(prefix = "${room.id}-cover") + + // 커버 이미지 업로드 + val coverImagePath = s3Uploader.upload( + inputStream = coverImage.inputStream, + bucket = coverImageBucket, + filePath = "live_room_cover/${room.id}/$coverImageFileName", + metadata = metadata + ) + + room.coverImage = coverImagePath + room.bgImage = coverImagePath + } else { + room.coverImage = request.coverImageUrl + room.bgImage = request.coverImageUrl + } + + applicationEventPublisher.publishEvent( + FcmEvent( + type = FcmEventType.CREATE_LIVE, + title = createdRoom.member!!.nickname, + message = if (createdRoom.channelName != null) { + "라이브를 시작했습니다. - ${createdRoom.title}" + } else { + "라이브를 개설했습니다. - ${createdRoom.title}" + }, + isAuth = createdRoom.isAdult, + roomId = createdRoom.id, + creatorId = createdRoom.member!!.id, + container = "ios" + ) + ) + + applicationEventPublisher.publishEvent( + FcmEvent( + type = FcmEventType.CREATE_LIVE, + title = createdRoom.member!!.nickname, + message = if (createdRoom.channelName != null) { + "라이브를 시작했습니다. - ${createdRoom.title}" + } else { + "라이브를 개설했습니다. - ${createdRoom.title}" + }, + isAuth = createdRoom.isAdult, + roomId = createdRoom.id, + creatorId = createdRoom.member!!.id, + container = "aos" + ) + ) + + return CreateLiveRoomResponse(createdRoom.id, createdRoom.channelName) + } + + fun getRoomDetail(roomId: Long, member: Member, timezone: String): GetRoomDetailResponse { + val room = repository.getLiveRoom(id = roomId) + ?: throw SodaException("이미 종료된 방입니다") + + if (room.isAdult && member.auth == null) { + throw SodaException("본인인증이 필요한 서비스 입니다.\n요즘라이브 마이페이지에서 본인인증 후 다시 이용해 주세요.") + } + + val beginDateTime = room.beginDateTime + .atZone(ZoneId.of("UTC")) + .withZoneSameInstant(ZoneId.of(timezone)) + + val response = GetRoomDetailResponse( + roomId = roomId, + title = room.title, + notice = room.notice, + price = room.price, + tags = room.tags.asSequence().filter { it.tag.isActive }.map { it.tag.tag }.toList(), + numberOfParticipantsTotal = room.numberOfPeople, + numberOfParticipants = 0, + channelName = room.channelName, + beginDateTime = beginDateTime.format(DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a")), + isPaid = false, + isPrivateRoom = room.type == LiveRoomType.PRIVATE, + password = room.password + ) + response.manager = GetRoomDetailManager(room.member!!, cloudFrontHost = cloudFrontHost) + + if (!room.channelName.isNullOrBlank()) { + val roomInfo = roomInfoRepository.findByIdOrNull(roomId) + + if (roomInfo != null) { + response.isPaid = canRepository.isExistPaidLiveRoom( + memberId = member.id!!, + roomId = roomId + ) != null + + val users = roomInfo.speakerList + .asSequence() + .map { + GetRoomDetailUser( + it.id, + it.nickname, + "$cloudFrontHost/${it.profileImage}" + ) + }.toMutableList() + + users.addAll( + roomInfo.listenerList + .asSequence() + .map { + GetRoomDetailUser( + it.id, + it.nickname, + "$cloudFrontHost/${it.profileImage}" + ) + } + ) + + users.addAll( + roomInfo.managerList + .asSequence() + .map { + GetRoomDetailUser( + it.id, + it.nickname, + "$cloudFrontHost/${it.profileImage}" + ) + } + ) + + response.participatingUsers = users + response.numberOfParticipants = users.size + } + } else { + val reservationList = reservationRepository.getReservationList(roomId) + response.participatingUsers = reservationList + .asSequence() + .map { + if (it.member!!.id!! == member.id!!) { + response.isPaid = true + } + + GetRoomDetailUser(it.member!!, cloudFrontHost) + } + .toList() + response.numberOfParticipants = reservationList.size + } + + return response + } + + @Transactional + fun startLive(request: StartLiveRequest, member: Member) { + val room = repository.getLiveRoomAndAccountId(request.roomId, member.id!!) + ?: throw SodaException("해당하는 라이브가 없습니다.") + + val dateTime = LocalDateTime.now() + .atZone(ZoneId.of("UTC")) + .withZoneSameInstant(ZoneId.of(request.timezone)) + .toLocalDateTime() + + val beginDateTime = room.beginDateTime + .atZone(ZoneId.of("UTC")) + .withZoneSameInstant(ZoneId.of(request.timezone)) + .toLocalDateTime() + + if (dateTime.plusMinutes(10).isBefore(beginDateTime)) { + val startAvailableDateTimeString = beginDateTime.minusMinutes(10).format( + DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a") + ) + throw SodaException("$startAvailableDateTimeString 이후에 시작할 수 있습니다.") + } + + room.channelName = "SODA_LIVE_CHANNEL_" + + "${member.id}_${dateTime.year}_${dateTime.month}_${dateTime.dayOfMonth}_" + + "${dateTime.hour}_${dateTime.minute}" + + applicationEventPublisher.publishEvent( + FcmEvent( + type = FcmEventType.START_LIVE, + title = room.member!!.nickname, + message = "라이브를 시작했습니다 - ${room.title}", + isAuth = room.isAdult, + roomId = room.id, + creatorId = room.member!!.id, + container = "ios" + ) + ) + + applicationEventPublisher.publishEvent( + FcmEvent( + type = FcmEventType.START_LIVE, + title = room.member!!.nickname, + message = "라이브를 시작했습니다 - ${room.title}", + isAuth = room.isAdult, + roomId = room.id, + creatorId = room.member!!.id, + container = "aos" + ) + ) + } + + @Transactional + fun cancelLive(request: CancelLiveRequest, member: Member) { + val room = repository.getLiveRoomAndAccountId(request.roomId, member.id!!) + ?: throw SodaException("해당하는 라이브가 없습니다.") + + if (request.reason.isBlank()) { + throw SodaException("취소사유를 입력해 주세요.") + } + room.isActive = false + + val roomCancel = LiveRoomCancel(request.reason) + roomCancel.room = room + roomCancelRepository.save(roomCancel) + + // 유료방인 경우 환불처리 + if (room.price > 0) { + val bookerList = reservationRepository.getReservationBookerList(roomId = room.id!!) + for (booker in bookerList) { + val useCan = canRepository.getCanUsedForLiveRoomNotRefund( + memberId = booker.id!!, + roomId = room.id!!, + canUsage = CanUsage.LIVE + ) ?: continue + useCan.isRefund = true + + val useCanCalculate = useCanCalculateRepository.findByUseCanIdAndStatus(useCanId = useCan.id!!) + useCanCalculate.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 -> booker.pgRewardCan += charge.rewardCan + PaymentGateway.GOOGLE_IAP -> booker.googleRewardCan += charge.rewardCan + PaymentGateway.APPLE_IAP -> booker.appleRewardCan += charge.rewardCan + } + charge.member = booker + + val payment = Payment( + status = PaymentStatus.COMPLETE, + paymentGateway = it.paymentGateway + ) + payment.method = "환불" + charge.payment = payment + + chargeRepository.save(charge) + } + } + } + + reservationRepository.cancelReservation(roomId = room.id!!) + } + + @Transactional + fun enterLive(request: EnterOrQuitLiveRoomRequest, member: Member) { + val room = repository.getLiveRoom(id = request.roomId) + ?: throw SodaException("해당하는 라이브가 없습니다.") + + if ( + room.member!!.id!! != member.id!! && + room.type == LiveRoomType.PRIVATE && + (request.password == null || request.password != room.password) + ) { + throw SodaException("비밀번호가 일치하지 않습니다.\n다시 확인 후 입력해주세요.") + } + + val isBlocked = blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = room.member!!.id!!) + if (isBlocked) throw SodaException("${room.member!!.nickname}님의 요청으로 라이브에 입장할 수 없습니다.") + + val kickOutCount = kickOutService.getKickOutCount(roomId = room.id!!, userId = member.id!!) + if (kickOutCount >= 2) throw SodaException("${room.member!!.nickname}님의 요청으로 라이브에 참여할 수 없습니다.") + + val lock = getOrCreateLock(memberId = member.id!!) + lock.write { + var roomInfo = roomInfoRepository.findByIdOrNull(request.roomId) + if (roomInfo == null) { + roomInfo = roomInfoRepository.save(LiveRoomInfo(roomId = request.roomId)) + } + + if (roomInfo.speakerCount + roomInfo.listenerCount + roomInfo.managerCount >= room.numberOfPeople) { + throw SodaException("방이 가득찼습니다.") + } + + if ( + room.price > 0 && + room.member!!.id!! != member.id!! && + canRepository.isExistPaidLiveRoom(memberId = member.id!!, roomId = request.roomId) == null + ) { + val findMember = memberRepository.findByIdOrNull(id = member.id!!) + ?: throw SodaException("로그인 정보를 확인해 주세요.") + + val totalCan = findMember.getChargeCan(request.container) + findMember.getRewardCan(request.container) + if (totalCan < room.price) { + throw SodaException("${room.price - totalCan}캔이 부족합니다. 충전 후 이용해 주세요.") + } + + canPaymentService.spendCan( + memberId = member.id!!, + needCan = room.price, + canUsage = CanUsage.LIVE, + liveRoom = room, + container = request.container + ) + } + + roomInfo.removeListener(member) + roomInfo.removeSpeaker(member) + roomInfo.removeManager(member) + + if (room.member!!.id == member.id) { + roomInfo.addSpeaker(member, cloudFrontHost) + } else { + roomInfo.addListener(member, cloudFrontHost) + } + + roomInfoRepository.save(roomInfo) + roomVisitService.roomVisit(room, member) + } + } + + fun getRecentRoomInfo(member: Member): GetRecentRoomInfoResponse { + return repository.getRecentRoomInfo(memberId = member.id!!, cloudFrontHost = cloudFrontHost) + ?: throw SodaException("최근 데이터가 없습니다.") + } + + @Transactional + fun editLiveRoomInfo(roomId: Long, coverImage: MultipartFile?, requestString: String?, member: Member) { + val room = repository.getLiveRoom(roomId) + if (member.id == null || room?.member?.id != member.id!!) { + throw SodaException("잘못된 요청입니다.") + } + + if (coverImage == null && requestString == null) { + throw SodaException("변경사항이 없습니다.") + } + + if (requestString != null) { + val request = objectMapper.readValue(requestString, EditLiveRoomInfoRequest::class.java) + + if (request.title != null) { + room.title = request.title + } + + if (request.notice != null) { + room.notice = request.notice + } + + if (request.numberOfPeople != null) { + room.numberOfPeople = request.numberOfPeople + } + + if (request.beginDateTimeString != null && request.timezone != null) { + room.beginDateTime = request.beginDateTimeString.convertLocalDateTime("yyyy-MM-dd HH:mm") + .atZone(ZoneId.of(request.timezone)) + .withZoneSameInstant(ZoneId.of("UTC")) + .toLocalDateTime() + } + } + + if (coverImage != null) { + val metadata = ObjectMetadata() + metadata.contentLength = coverImage.size + + // 커버 이미지 파일명 생성 + val coverImageFileName = generateFileName(prefix = "${room.id}-cover") + + // 커버 이미지 업로드 + val coverImagePath = s3Uploader.upload( + inputStream = coverImage.inputStream, + bucket = coverImageBucket, + filePath = "suda_room_cover/${room.id}/$coverImageFileName", + metadata = metadata + ) + + room.bgImage = coverImagePath + + if (room.channelName == null) { + room.coverImage = coverImagePath + } + } + } + + fun getRoomInfo(roomId: Long, member: Member): GetRoomInfoResponse { + val roomInfo = roomInfoRepository.findByIdOrNull(roomId) + ?: throw SodaException("해당하는 라이브의 정보가 없습니다.") + + val room = repository.findByIdOrNull(roomId) + ?: throw SodaException("해당하는 라이브의 정보가 없습니다.") + + val currentTimeStamp = Date().time + val expireTimestamp = (currentTimeStamp + (60 * 60 * 24 * 1000)) / 1000 + + val rtcToken = rtcTokenBuilder.buildTokenWithUid( + agoraAppId, + agoraAppCertificate, + room.channelName!!, + member.id!!.toInt(), + expireTimestamp.toInt() + ) + + val rtmToken = rtmTokenBuilder.buildToken( + agoraAppId, + agoraAppCertificate, + member.id!!.toString(), + expireTimestamp.toInt() + ) + + val isFollowing = explorerQueryRepository + .getNotificationUserIds(room.member!!.id!!) + .contains(member.id) + + val donationRankingTop3UserIds = explorerQueryRepository + .getMemberDonationRanking( + room.member!!.id!!, + 3, + withDonationCan = false + ) + .asSequence() + .map { it.userId } + .toList() + + return GetRoomInfoResponse( + roomId = roomId, + title = room.title, + notice = room.notice, + coverImageUrl = if (room.bgImage != null) { + if (room.bgImage!!.startsWith("https://")) { + room.bgImage!! + } else { + "$cloudFrontHost/${room.bgImage!!}" + } + } else { + if (room.coverImage!!.startsWith("https://")) { + room.coverImage!! + } else { + "$cloudFrontHost/${room.coverImage!!}" + } + }, + channelName = room.channelName!!, + rtcToken = rtcToken, + rtmToken = rtmToken, + creatorId = room.member!!.id!!, + creatorNickname = room.member!!.nickname, + creatorProfileUrl = if (room.member!!.profileImage != null) { + "$cloudFrontHost/${room.member!!.profileImage}" + } else { + "$cloudFrontHost/profile/default-profile.png" + }, + isFollowing = isFollowing, + participantsCount = roomInfo.listenerCount + roomInfo.speakerCount + roomInfo.managerCount, + totalAvailableParticipantsCount = room.numberOfPeople, + speakerList = roomInfo.speakerList, + listenerList = roomInfo.listenerList, + managerList = roomInfo.managerList, + donationRankingTop3UserIds = donationRankingTop3UserIds, + isPrivateRoom = room.type == LiveRoomType.PRIVATE, + password = room.password + ) + } + + fun getDonationMessageList(roomId: Long, member: Member): List { + val room = repository.findByIdOrNull(roomId) + ?: throw SodaException("해당하는 라이브가 없습니다.") + + if (member.id!! != room.member!!.id!!) { + throw SodaException("잘못된 요청입니다.") + } + + val roomInfo = roomInfoRepository.findByIdOrNull(roomId) + ?: throw SodaException("해당하는 라이브의 정보가 없습니다.") + + return roomInfo.donationMessageList + } + + fun deleteDonationMessage(request: DeleteLiveRoomDonationMessage, member: Member) { + val lock = getOrCreateLock(memberId = member.id!!) + lock.write { + val room = repository.findByIdOrNull(request.roomId) + ?: throw SodaException("해당하는 라이브가 없습니다.") + + if (member.id!! != room.member!!.id!!) { + throw SodaException("잘못된 요청입니다.") + } + + val roomInfo = roomInfoRepository.findByIdOrNull(request.roomId) + ?: throw SodaException("해당하는 라이브의 정보가 없습니다.") + + roomInfo.removeDonationMessage(request.messageUUID) + roomInfoRepository.save(roomInfo) + } + } + + fun getUserProfile(roomId: Long, userId: Long, member: Member): GetLiveRoomUserProfileResponse { + val room = repository.getLiveRoom(roomId) + ?: throw SodaException("해당하는 라이브의 정보가 없습니다.") + val roomInfo = roomInfoRepository.findByIdOrNull(roomId) + ?: throw SodaException("해당하는 라이브의 정보가 없습니다.") + + val user = memberRepository.findByIdOrNull(userId) + ?: throw SodaException("잘못된 요청입니다.") + + val isFollowing = if (user.role == MemberRole.CREATOR) { + explorerQueryRepository + .getNotificationUserIds(userId) + .contains(member.id!!) + } else { + null + } + + // 조회 하는 유저 + val memberResponse = LiveRoomMember(member, cloudFrontHost) + // 조회 당하는 유저 + val userResponse = LiveRoomMember(user, cloudFrontHost) + + val isSpeaker = if ( + room.member!!.id!! != userId && + (room.member!!.id!! == member.id!! || roomInfo.managerList.contains(memberResponse)) + ) { + roomInfo.speakerList.contains(userResponse) + } else { + null + } + + val isManager = if (room.member!!.id!! != userId && room.member!!.id!! == member.id!!) { + roomInfo.managerList.contains(userResponse) + } else { + null + } + + return GetLiveRoomUserProfileResponse( + userId = user.id!!, + nickname = user.nickname, + profileUrl = if (user.profileImage != null) { + "$cloudFrontHost/${user.profileImage}" + } else { + "$cloudFrontHost/profile/default-profile.png" + }, + gender = if (user.gender == Gender.FEMALE) "여" else if (user.gender == Gender.MALE) "남" else "미", + instagramUrl = user.instagramUrl, + youtubeUrl = user.youtubeUrl, + websiteUrl = user.websiteUrl, + blogUrl = user.blogUrl, + introduce = user.introduce, + tags = user.tags + .asSequence() + .filter { it.tag.isActive } + .map { it.tag.tag } + .joinToString(" ") { tag -> "#$tag" }, + isSpeaker = isSpeaker, + isManager = isManager, + isFollowing = isFollowing, + isBlock = blockMemberRepository.isBlocked(blockedMemberId = userResponse.id, memberId = memberResponse.id) + ) + } + + fun getDonationTotal(roomId: Long): GetLiveRoomDonationTotalResponse { + return GetLiveRoomDonationTotalResponse( + totalDonationCan = repository.getDonationTotal(roomId = roomId) ?: 0 + ) + } + + fun setSpeaker(request: SetManagerOrSpeakerOrAudienceRequest) { + val lock = getOrCreateLock(memberId = request.memberId) + lock.write { + val roomInfo = roomInfoRepository.findByIdOrNull(request.roomId) + ?: throw SodaException("해당하는 라이브의 정보가 없습니다.") + + val member = memberRepository.findByIdOrNull(request.memberId) + ?: throw SodaException("로그인 정보를 확인해 주세요.") + + if (roomInfo.speakerCount > 9) { + throw SodaException("스피커 정원이 초과하였습니다.") + } + + roomInfo.removeListener(member) + roomInfo.removeManager(member) + roomInfo.addSpeaker(member, cloudFrontHost) + + roomInfoRepository.save(roomInfo) + } + } + + fun setListener(request: SetManagerOrSpeakerOrAudienceRequest) { + val lock = getOrCreateLock(memberId = request.memberId) + lock.write { + val roomInfo = roomInfoRepository.findByIdOrNull(request.roomId) + ?: throw SodaException("해당하는 라이브의 정보가 없습니다.") + + val member = memberRepository.findByIdOrNull(request.memberId) + ?: throw SodaException("로그인 정보를 확인해 주세요.") + + roomInfo.removeSpeaker(member) + roomInfo.removeManager(member) + roomInfo.addListener(member, cloudFrontHost) + + roomInfoRepository.save(roomInfo) + } + } + + fun setManager(request: SetManagerOrSpeakerOrAudienceRequest, member: Member) { + val lock = getOrCreateLock(memberId = member.id!!) + lock.write { + val room = repository.getLiveRoom(request.roomId) ?: throw SodaException("잘못된 요청입니다.") + if (room.member!!.id!! != member.id!!) { + throw SodaException("권한이 없습니다.") + } + + val user = memberRepository.findByIdOrNull(request.memberId) ?: throw SodaException("해당하는 유저가 없습니다.") + val roomInfo = roomInfoRepository.findByIdOrNull(request.roomId) + ?: throw SodaException("해당하는 라이브의 정보가 없습니다.") + + val roomAccountResponse = LiveRoomMember(member = user, cloudFrontHost) + if (roomInfo.managerList.contains(roomAccountResponse)) { + throw SodaException("이미 매니저 입니다.") + } + + if ( + !roomInfo.speakerList.contains(roomAccountResponse) && + !roomInfo.listenerList.contains(roomAccountResponse) + ) { + throw SodaException("해당하는 유저가 없습니다.") + } + + roomInfo.removeListener(user) + roomInfo.removeSpeaker(user) + roomInfo.addManager(user, cloudFrontHost) + + roomInfoRepository.save(roomInfo) + } + } + + @Transactional + fun donation(request: LiveRoomDonationRequest, member: Member) { + val room = repository.findByIdOrNull(request.roomId) + ?: throw SodaException("해당하는 라이브가 없습니다.") + + val host = room.member ?: throw SodaException("잘못된 요청입니다.") + + if (host.role != MemberRole.CREATOR) { + throw SodaException("비비드넥스트와 계약한\n크리에이터에게만 후원을 하실 수 있습니다.") + } + + canPaymentService.spendCan( + memberId = member.id!!, + needCan = request.can, + canUsage = CanUsage.DONATION, + liveRoom = room, + container = request.container + ) + + if (request.message.isNotBlank()) { + val lock = getOrCreateLock(memberId = member.id!!) + lock.write { + val roomInfo = roomInfoRepository.findByIdOrNull(room.id!!) + ?: throw SodaException("해당하는 라이브의 정보가 없습니다.") + + roomInfo.addDonationMessage( + nickname = member.nickname, + can = request.can, + donationMessage = request.message + ) + + roomInfoRepository.save(roomInfo) + } + } + } + + @Transactional + fun refundDonation(roomId: Long, member: Member) { + val donator = memberRepository.findByIdOrNull(member.id) + ?: throw SodaException("후원에 실패한 캔이 환불되지 않았습니다\n고객센터로 문의해주세요.") + + val useCan = canRepository.getCanUsedForLiveRoomNotRefund( + memberId = member.id!!, + roomId = roomId, + canUsage = CanUsage.DONATION + ) ?: throw SodaException("후원에 실패한 캔이 환불되지 않았습니다\n고객센터로 문의해주세요.") + 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 -> donator.pgRewardCan += charge.rewardCan + PaymentGateway.GOOGLE_IAP -> donator.googleRewardCan += charge.rewardCan + PaymentGateway.APPLE_IAP -> donator.appleRewardCan += charge.rewardCan + } + charge.member = donator + + val payment = Payment( + status = PaymentStatus.COMPLETE, + paymentGateway = it.paymentGateway + ) + payment.method = "환불" + charge.payment = payment + + chargeRepository.save(charge) + } + } + + fun getDonationStatus(roomId: Long, member: Member): GetLiveRoomDonationStatusResponse { + val room = repository.getLiveRoom(roomId) ?: throw SodaException("잘못된 요청입니다.") + val donationList = repository.getDonationList(roomId = room.id!!, cloudFrontHost = cloudFrontHost) + val totalCan = donationList.sumOf { it.can } + + return GetLiveRoomDonationStatusResponse( + donationList = donationList, + totalCount = donationList.size, + totalCan = totalCan + ) + } + + @Transactional + fun quitRoom(roomId: Long, member: Member) { + val room = repository.getLiveRoom(roomId) + val lock = getOrCreateLock(memberId = member.id!!) + lock.write { + val roomInfo = roomInfoRepository.findByIdOrNull(roomId) + if (roomInfo != null) { + if (room?.member != null && room.member!! == member) { + room.isActive = false + kickOutService.deleteKickOutData(roomId = room.id!!) + roomInfoRepository.deleteById(roomInfo.roomId) + } else { + roomInfo.removeSpeaker(member) + roomInfo.removeListener(member) + roomInfo.removeManager(member) + roomInfoRepository.save(roomInfo) + } + } + } + } + + private fun getOrCreateLock(memberId: Long): ReentrantReadWriteLock { + return tokenLocks.computeIfAbsent(memberId) { ReentrantReadWriteLock() } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomTag.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomTag.kt new file mode 100644 index 0000000..c822931 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomTag.kt @@ -0,0 +1,19 @@ +package kr.co.vividnext.sodalive.live.room + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.live.tag.LiveTag +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne + +@Entity +class LiveRoomTag( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "live_room_id", nullable = false) + var room: LiveRoom, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "live_tag_id", nullable = false) + var tag: LiveTag +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/SetManagerOrSpeakerOrAudienceRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/SetManagerOrSpeakerOrAudienceRequest.kt new file mode 100644 index 0000000..5d41045 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/SetManagerOrSpeakerOrAudienceRequest.kt @@ -0,0 +1,6 @@ +package kr.co.vividnext.sodalive.live.room + +data class SetManagerOrSpeakerOrAudienceRequest( + val roomId: Long, + val memberId: Long +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/StartLiveRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/StartLiveRequest.kt new file mode 100644 index 0000000..b59570f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/StartLiveRequest.kt @@ -0,0 +1,8 @@ +package kr.co.vividnext.sodalive.live.room + +import java.util.TimeZone + +data class StartLiveRequest( + val roomId: Long, + val timezone: String = TimeZone.getDefault().id +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/cancel/CancelLiveRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/cancel/CancelLiveRequest.kt new file mode 100644 index 0000000..d6add40 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/cancel/CancelLiveRequest.kt @@ -0,0 +1,3 @@ +package kr.co.vividnext.sodalive.live.room.cancel + +data class CancelLiveRequest(val roomId: Long, val reason: String) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/cancel/LiveRoomCancel.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/cancel/LiveRoomCancel.kt new file mode 100644 index 0000000..e4bc12c --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/cancel/LiveRoomCancel.kt @@ -0,0 +1,21 @@ +package kr.co.vividnext.sodalive.live.room.cancel + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.live.room.LiveRoom +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.OneToOne + +@Entity +data class LiveRoomCancel( + val reason: String +) : BaseEntity() { + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "room_id", nullable = false) + var room: LiveRoom? = null + set(value) { + value?.cancel = this + field = value + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/cancel/LiveRoomCancelRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/cancel/LiveRoomCancelRepository.kt new file mode 100644 index 0000000..959a77d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/cancel/LiveRoomCancelRepository.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.live.room.cancel + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface LiveRoomCancelRepository : JpaRepository diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/detail/GetRoomDetailResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/detail/GetRoomDetailResponse.kt new file mode 100644 index 0000000..be95f68 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/detail/GetRoomDetailResponse.kt @@ -0,0 +1,66 @@ +package kr.co.vividnext.sodalive.live.room.detail + +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole + +data class GetRoomDetailResponse( + val roomId: Long, + val price: Int, + val title: String, + val notice: String, + var isPaid: Boolean, + val isPrivateRoom: Boolean, + val password: String?, + val tags: List, + val channelName: String?, + val beginDateTime: String, + var numberOfParticipants: Int, + val numberOfParticipantsTotal: Int +) { + var manager: GetRoomDetailManager? = null + var participatingUsers: List = listOf() +} + +data class GetRoomDetailManager( + val id: Long, + val nickname: String, + val introduce: String, + val youtubeUrl: String?, + val instagramUrl: String?, + val websiteUrl: String?, + val blogUrl: String?, + val profileImageUrl: String, + val isCreator: Boolean +) { + constructor(member: Member, cloudFrontHost: String) : this( + id = member.id!!, + nickname = member.nickname, + introduce = member.introduce, + youtubeUrl = member.youtubeUrl, + instagramUrl = member.instagramUrl, + websiteUrl = member.websiteUrl, + blogUrl = member.blogUrl, + profileImageUrl = if (member.profileImage != null) { + "$cloudFrontHost/${member.profileImage}" + } else { + "$cloudFrontHost/profile/default-profile.png" + }, + isCreator = member.role == MemberRole.CREATOR + ) +} + +data class GetRoomDetailUser( + val id: Long, + val nickname: String, + val profileImageUrl: String +) { + constructor(member: Member, cloudFrontHost: String) : this( + member.id!!, + member.nickname, + profileImageUrl = if (member.profileImage != null) { + "$cloudFrontHost/${member.profileImage}" + } else { + "$cloudFrontHost/profile/default-profile.png" + } + ) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/donation/DeleteLiveRoomDonationMessage.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/donation/DeleteLiveRoomDonationMessage.kt new file mode 100644 index 0000000..4677041 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/donation/DeleteLiveRoomDonationMessage.kt @@ -0,0 +1,6 @@ +package kr.co.vividnext.sodalive.live.room.donation + +data class DeleteLiveRoomDonationMessage( + val roomId: Long, + val messageUUID: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/donation/GetLiveRoomDonationStatusResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/donation/GetLiveRoomDonationStatusResponse.kt new file mode 100644 index 0000000..2cc8a12 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/donation/GetLiveRoomDonationStatusResponse.kt @@ -0,0 +1,16 @@ +package kr.co.vividnext.sodalive.live.room.donation + +import com.querydsl.core.annotations.QueryProjection + +data class GetLiveRoomDonationStatusResponse( + val donationList: List, + val totalCount: Int, + val totalCan: Int +) + +data class GetLiveRoomDonationItem @QueryProjection constructor( + val profileImage: String, + val nickname: String, + val userId: Long, + val can: Int +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/donation/GetLiveRoomDonationTotalResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/donation/GetLiveRoomDonationTotalResponse.kt new file mode 100644 index 0000000..2db7f9b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/donation/GetLiveRoomDonationTotalResponse.kt @@ -0,0 +1,3 @@ +package kr.co.vividnext.sodalive.live.room.donation + +data class GetLiveRoomDonationTotalResponse(val totalDonationCan: Int) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/donation/LiveRoomDonationMessage.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/donation/LiveRoomDonationMessage.kt new file mode 100644 index 0000000..52bb4a1 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/donation/LiveRoomDonationMessage.kt @@ -0,0 +1,10 @@ +package kr.co.vividnext.sodalive.live.room.donation + +import java.util.UUID + +data class LiveRoomDonationMessage( + val uuid: String = UUID.randomUUID().toString(), + val nickname: String, + val canMessage: String, + val donationMessage: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/donation/LiveRoomDonationRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/donation/LiveRoomDonationRequest.kt new file mode 100644 index 0000000..732bd84 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/donation/LiveRoomDonationRequest.kt @@ -0,0 +1,8 @@ +package kr.co.vividnext.sodalive.live.room.donation + +data class LiveRoomDonationRequest( + val roomId: Long, + val can: Int, + val container: String, + val message: String = "" +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/info/GetRoomInfoResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/info/GetRoomInfoResponse.kt new file mode 100644 index 0000000..69ad47d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/info/GetRoomInfoResponse.kt @@ -0,0 +1,23 @@ +package kr.co.vividnext.sodalive.live.room.info + +data class GetRoomInfoResponse( + val roomId: Long, + val title: String, + val notice: String, + val coverImageUrl: String, + val channelName: String, + val rtcToken: String, + val rtmToken: String, + val creatorId: Long, + val creatorNickname: String, + val creatorProfileUrl: String, + val isFollowing: Boolean, + val participantsCount: Int, + val totalAvailableParticipantsCount: Int, + val speakerList: List, + val listenerList: List, + val managerList: List, + val donationRankingTop3UserIds: List, + val isPrivateRoom: Boolean = false, + val password: String? = null +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/info/LiveRoomInfo.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/info/LiveRoomInfo.kt new file mode 100644 index 0000000..7bb3ca9 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/info/LiveRoomInfo.kt @@ -0,0 +1,101 @@ +package kr.co.vividnext.sodalive.live.room.info + +import kr.co.vividnext.sodalive.live.room.donation.LiveRoomDonationMessage +import kr.co.vividnext.sodalive.member.Member +import org.springframework.data.annotation.Id +import org.springframework.data.redis.core.RedisHash + +@RedisHash("live_room_info") +data class LiveRoomInfo( + @Id + val roomId: Long, + var speakerList: List = mutableListOf(), + var listenerList: List = mutableListOf(), + var managerList: List = mutableListOf(), + var donationMessageList: List = mutableListOf() +) { + var speakerCount = 0 + private set + + var listenerCount = 0 + private set + + var managerCount = 0 + private set + + fun addSpeaker(member: Member, cloudFrontHost: String) { + val liveRoomMember = LiveRoomMember(member, cloudFrontHost) + liveRoomMember.role = LiveRoomMemberRole.SPEAKER + + val speakerSet = speakerList.toMutableSet() + speakerSet.add(liveRoomMember) + speakerList = speakerSet.toList() + + setSpeakerCount() + } + + fun removeSpeaker(member: Member) { + (speakerList as MutableList).removeIf { it.id == member.id!! } + setSpeakerCount() + } + + private fun setSpeakerCount() { + speakerCount = speakerList.size + } + + fun addListener(member: Member, cloudFrontHost: String) { + val liveRoomMember = LiveRoomMember(member, cloudFrontHost) + liveRoomMember.role = LiveRoomMemberRole.LISTENER + + val listenerSet = listenerList.toMutableSet() + listenerSet.add(liveRoomMember) + listenerList = listenerSet.toList() + + setListenerCount() + } + + fun removeListener(member: Member) { + (listenerList as MutableList).removeIf { it.id == member.id!! } + setListenerCount() + } + + private fun setListenerCount() { + listenerCount = listenerList.size + } + + fun addManager(member: Member, cloudFrontHost: String) { + val liveRoomMember = LiveRoomMember(member, cloudFrontHost) + liveRoomMember.role = LiveRoomMemberRole.MANAGER + + val managerSet = managerList.toMutableSet() + managerSet.add(liveRoomMember) + managerList = managerSet.toList() + + setManagerCount() + } + + fun removeManager(member: Member) { + (managerList as MutableList).removeIf { it.id == member.id!! } + setManagerCount() + } + + private fun setManagerCount() { + managerCount = managerList.size + } + + fun addDonationMessage(nickname: String, can: Int, donationMessage: String) { + val donationMessageSet = donationMessageList.toMutableSet() + donationMessageSet.add( + LiveRoomDonationMessage( + nickname = nickname, + canMessage = "${can}캔을 후원하셨습니다.", + donationMessage = donationMessage + ) + ) + donationMessageList = donationMessageSet.toList() + } + + fun removeDonationMessage(uuid: String) { + (donationMessageList as MutableList).removeIf { it.uuid == uuid } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/info/LiveRoomInfoRedisRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/info/LiveRoomInfoRedisRepository.kt new file mode 100644 index 0000000..a39c911 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/info/LiveRoomInfoRedisRepository.kt @@ -0,0 +1,5 @@ +package kr.co.vividnext.sodalive.live.room.info + +import org.springframework.data.repository.CrudRepository + +interface LiveRoomInfoRedisRepository : CrudRepository diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/info/LiveRoomMember.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/info/LiveRoomMember.kt new file mode 100644 index 0000000..0663059 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/info/LiveRoomMember.kt @@ -0,0 +1,25 @@ +package kr.co.vividnext.sodalive.live.room.info + +import kr.co.vividnext.sodalive.member.Member + +data class LiveRoomMember( + val id: Long, + val nickname: String, + val profileImage: String +) { + var role = LiveRoomMemberRole.LISTENER + + constructor(member: Member, cloudFrontHost: String) : this( + id = member.id!!, + nickname = member.nickname, + profileImage = if (member.profileImage != null) { + "$cloudFrontHost/${member.profileImage}" + } else { + "$cloudFrontHost/profile/default-profile.png" + } + ) +} + +enum class LiveRoomMemberRole { + LISTENER, SPEAKER, MANAGER +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOut.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOut.kt new file mode 100644 index 0000000..aa4bc54 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOut.kt @@ -0,0 +1,32 @@ +package kr.co.vividnext.sodalive.live.room.kickout + +import org.springframework.data.annotation.Id +import org.springframework.data.redis.core.RedisHash + +@RedisHash("live_room_kick_out") +data class LiveRoomKickOut( + @Id + val roomId: Long, + var userList: MutableList = mutableListOf() +) { + fun kickOut(userId: Long) { + var liveRoomKickOutUser = userList.find { it.userId == userId } + if (liveRoomKickOutUser == null) { + liveRoomKickOutUser = LiveRoomKickOutUser(userId) + } else { + liveRoomKickOutUser.plusCount() + } + + userList.removeIf { it.userId == userId } + userList.add(liveRoomKickOutUser) + } +} + +data class LiveRoomKickOutUser( + val userId: Long, + var count: Int = 1 +) { + fun plusCount() { + count += 1 + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOutController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOutController.kt new file mode 100644 index 0000000..fa77973 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOutController.kt @@ -0,0 +1,25 @@ +package kr.co.vividnext.sodalive.live.room.kickout + +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.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("/live/room/kick-out") +class LiveRoomKickOutController(private val service: LiveRoomKickOutService) { + + @PostMapping + fun liveRoomKickOut( + @RequestBody request: LiveRoomKickOutRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.kickOut(request = request, member = member)) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOutRedisRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOutRedisRepository.kt new file mode 100644 index 0000000..fb01cd8 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOutRedisRepository.kt @@ -0,0 +1,5 @@ +package kr.co.vividnext.sodalive.live.room.kickout + +import org.springframework.data.repository.CrudRepository + +interface LiveRoomKickOutRedisRepository : CrudRepository diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOutRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOutRequest.kt new file mode 100644 index 0000000..612d5d2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOutRequest.kt @@ -0,0 +1,6 @@ +package kr.co.vividnext.sodalive.live.room.kickout + +data class LiveRoomKickOutRequest( + val roomId: Long, + val userId: Long +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOutService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOutService.kt new file mode 100644 index 0000000..b193a82 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOutService.kt @@ -0,0 +1,65 @@ +package kr.co.vividnext.sodalive.live.room.kickout + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.live.room.LiveRoomRepository +import kr.co.vividnext.sodalive.live.room.info.LiveRoomInfoRedisRepository +import kr.co.vividnext.sodalive.live.room.info.LiveRoomMember +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import org.springframework.beans.factory.annotation.Value +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service + +@Service +class LiveRoomKickOutService( + private val roomInfoRepository: LiveRoomInfoRedisRepository, + private val repository: LiveRoomKickOutRedisRepository, + private val memberRepository: MemberRepository, + private val roomRepository: LiveRoomRepository, + + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) { + fun kickOut(request: LiveRoomKickOutRequest, member: Member) { + val room = roomRepository.findByIdOrNull(request.roomId) + ?: throw SodaException("해당하는 라이브가 없습니다.") + + val roomInfo = roomInfoRepository.findByIdOrNull(request.roomId) + ?: throw SodaException("해당하는 라이브의 정보가 없습니다.") + + if (room.member == null || room.member!!.id == null) { + throw SodaException("해당하는 라이브가 없습니다.") + } + + if (!roomInfo.managerList.contains(LiveRoomMember(member, cloudFrontHost)) && room.member!!.id != member.id) { + throw SodaException("권한이 없습니다.") + } + + var liveRoomKickOut = repository.findByIdOrNull(request.roomId) + if (liveRoomKickOut == null) { + liveRoomKickOut = repository.save(LiveRoomKickOut(roomId = request.roomId)) + } + + liveRoomKickOut.kickOut(request.userId) + repository.save(liveRoomKickOut) + + val kickOutUser = memberRepository.findByIdOrNull(request.userId) + if (kickOutUser != null) { + roomInfo.removeSpeaker(kickOutUser) + roomInfo.removeListener(kickOutUser) + roomInfo.removeManager(kickOutUser) + roomInfoRepository.save(roomInfo) + } + } + + fun getKickOutCount(roomId: Long, userId: Long): Int { + val liveRoomKickOut = repository.findByIdOrNull(roomId) ?: return 0 + + val findUser = liveRoomKickOut.userList.find { it.userId == userId } ?: return 0 + return findUser.count + } + + fun deleteKickOutData(roomId: Long) { + repository.deleteById(roomId) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/visit/GetRecentVisitRoomMemberResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/visit/GetRecentVisitRoomMemberResponse.kt new file mode 100644 index 0000000..a5dc76c --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/visit/GetRecentVisitRoomMemberResponse.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.live.room.visit + +data class GetRecentVisitRoomMemberResponse( + val userId: Long, + val nickname: String, + val profileImageUrl: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/visit/LiveRoomVisit.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/visit/LiveRoomVisit.kt new file mode 100644 index 0000000..622a585 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/visit/LiveRoomVisit.kt @@ -0,0 +1,24 @@ +package kr.co.vividnext.sodalive.live.room.visit + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.live.room.LiveRoom +import kr.co.vividnext.sodalive.member.Member +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne + +@Entity +class LiveRoomVisit : BaseEntity() { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + var member: Member? = null + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "room_id", nullable = false) + var room: LiveRoom? = null + set(value) { + value?.visits?.add(this) + field = value + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/visit/LiveRoomVisitRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/visit/LiveRoomVisitRepository.kt new file mode 100644 index 0000000..bbf9f90 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/visit/LiveRoomVisitRepository.kt @@ -0,0 +1,61 @@ +package kr.co.vividnext.sodalive.live.room.visit + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom +import kr.co.vividnext.sodalive.live.room.visit.QLiveRoomVisit.liveRoomVisit +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 +import org.springframework.stereotype.Repository + +@Repository +interface LiveRoomVisitRepository : JpaRepository, LiveRoomVisitQueryRepository + +interface LiveRoomVisitQueryRepository { + fun findByRoomIdAndMemberId(roomId: Long, memberId: Long): LiveRoomVisit? + fun findFirstByMemberIdOrderByUpdatedAtDesc(memberId: Long): LiveRoomVisit? + fun getRecentVisitRoomUsers(roomId: Long, memberId: Long): List +} + +@Repository +class LiveRoomVisitQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : LiveRoomVisitQueryRepository { + override fun findByRoomIdAndMemberId(roomId: Long, memberId: Long): LiveRoomVisit? { + return queryFactory + .selectFrom(liveRoomVisit) + .innerJoin(liveRoomVisit.room, liveRoom) + .innerJoin(liveRoomVisit.member, member) + .where( + liveRoom.id.eq(roomId) + .and(member.id.eq(memberId)) + ) + .orderBy(liveRoomVisit.id.desc()) + .fetchFirst() + } + + override fun findFirstByMemberIdOrderByUpdatedAtDesc(memberId: Long): LiveRoomVisit? { + return queryFactory + .selectFrom(liveRoomVisit) + .innerJoin(liveRoomVisit.room, liveRoom) + .innerJoin(liveRoomVisit.member, member) + .where(member.id.eq(memberId)) + .orderBy(liveRoomVisit.updatedAt.desc()) + .fetchFirst() + } + + override fun getRecentVisitRoomUsers(roomId: Long, memberId: Long): List { + return queryFactory + .select(member) + .from(liveRoomVisit) + .innerJoin(liveRoomVisit.member, member) + .innerJoin(liveRoomVisit.room, liveRoom) + .where( + liveRoomVisit.room.id.eq(roomId) + .and(liveRoomVisit.member.isActive.isTrue) + .and(liveRoomVisit.member.id.ne(memberId)) + .and(liveRoomVisit.member.role.ne(MemberRole.ADMIN)) + .and(liveRoomVisit.member.role.ne(MemberRole.AGENT)) + ) + .fetch() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/visit/LiveRoomVisitService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/visit/LiveRoomVisitService.kt new file mode 100644 index 0000000..0e89fd0 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/visit/LiveRoomVisitService.kt @@ -0,0 +1,42 @@ +package kr.co.vividnext.sodalive.live.room.visit + +import kr.co.vividnext.sodalive.live.room.LiveRoom +import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser +import kr.co.vividnext.sodalive.member.Member +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +@Transactional(readOnly = true) +class LiveRoomVisitService( + private val repository: LiveRoomVisitRepository, + + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) { + @Transactional + fun roomVisit(room: LiveRoom, member: Member) { + var roomVisit = repository.findByRoomIdAndMemberId(room.id!!, member.id!!) + if (roomVisit == null) { + roomVisit = LiveRoomVisit() + roomVisit.member = member + roomVisit.room = room + } else { + roomVisit.updatedAt = LocalDateTime.now() + } + + repository.save(roomVisit) + } + + fun getRecentVisitRoomUsers(memberId: Long): List { + val roomVisit = repository.findFirstByMemberIdOrderByUpdatedAtDesc(memberId) + ?: return emptyList() + + return repository.getRecentVisitRoomUsers(roomVisit.room!!.id!!, memberId) + .asSequence() + .map { GetRoomDetailUser(it, cloudFrontHost) } + .toList() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/tag/CreateLiveTagRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/tag/CreateLiveTagRequest.kt new file mode 100644 index 0000000..80a1b59 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/tag/CreateLiveTagRequest.kt @@ -0,0 +1,3 @@ +package kr.co.vividnext.sodalive.live.tag + +data class CreateLiveTagRequest(val tag: String) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/tag/GetLiveTagResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/tag/GetLiveTagResponse.kt new file mode 100644 index 0000000..e478871 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/tag/GetLiveTagResponse.kt @@ -0,0 +1,9 @@ +package kr.co.vividnext.sodalive.live.tag + +import com.querydsl.core.annotations.QueryProjection + +data class GetLiveTagResponse @QueryProjection constructor( + val id: Long, + val tag: String, + val image: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/tag/LiveTag.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/tag/LiveTag.kt new file mode 100644 index 0000000..99b0735 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/tag/LiveTag.kt @@ -0,0 +1,17 @@ +package kr.co.vividnext.sodalive.live.tag + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Column +import javax.persistence.Entity + +@Entity +data class LiveTag( + @Column(unique = true, nullable = false) + var tag: String, + @Column(nullable = true) + var image: String? = null, + @Column(nullable = false) + var isActive: Boolean = true, + @Column(nullable = false) + var orders: Int = 1 +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/tag/LiveTagController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/tag/LiveTagController.kt new file mode 100644 index 0000000..0e29647 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/tag/LiveTagController.kt @@ -0,0 +1,58 @@ +package kr.co.vividnext.sodalive.live.tag + +import kr.co.vividnext.sodalive.admin.member.tag.UpdateTagOrdersRequest +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.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +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("/live/tag") +class LiveTagController(private val service: LiveTagService) { + @PostMapping + @PreAuthorize("hasRole('ADMIN')") + fun enrollmentLiveTag( + @RequestPart("image") image: MultipartFile, + @RequestPart("request") requestString: String + ) = ApiResponse.ok(service.enrollmentLiveTag(image, requestString), "등록되었습니다.") + + @DeleteMapping("/{id}") + @PreAuthorize("hasRole('ADMIN')") + fun deleteSudaTag(@PathVariable id: Long) = ApiResponse.ok(service.deleteTag(id), "삭제되었습니다.") + + @PutMapping("/{id}") + @PreAuthorize("hasRole('ADMIN')") + fun modifySudaTag( + @PathVariable id: Long, + @RequestPart("image") image: MultipartFile?, + @RequestPart("request") requestString: String + ) = ApiResponse.ok(service.modifyTag(id, image, requestString), "수정되었습니다.") + + @PutMapping("/orders") + @PreAuthorize("hasRole('ADMIN')") + fun updateTagOrders( + @RequestBody request: UpdateTagOrdersRequest + ) = ApiResponse.ok(service.updateTagOrders(request.ids), "수정되었습니다.") + + @GetMapping + fun getTags( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) { + throw SodaException("로그인 정보를 확인해주세요.") + } + + ApiResponse.ok(service.getTags(member)) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/tag/LiveTagRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/tag/LiveTagRepository.kt new file mode 100644 index 0000000..009768f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/tag/LiveTagRepository.kt @@ -0,0 +1,40 @@ +package kr.co.vividnext.sodalive.live.tag + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.live.tag.QLiveTag.liveTag +import kr.co.vividnext.sodalive.member.MemberRole +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface LiveTagRepository : JpaRepository, LiveTagQueryRepository { + fun findByTag(it: String): LiveTag? +} + +interface LiveTagQueryRepository { + fun getTags(role: MemberRole, isAdult: Boolean, cloudFrontHost: String): List +} + +@Repository +class LiveTagQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : LiveTagQueryRepository { + override fun getTags(role: MemberRole, isAdult: Boolean, cloudFrontHost: String): List { + var where = liveTag.isActive.isTrue + + if (role != MemberRole.ADMIN && !isAdult) { + where = where.and(liveTag.tag.notIn("음담패설", "EDPS")) + } + + return queryFactory + .select( + QGetLiveTagResponse( + liveTag.id, + liveTag.tag, + liveTag.image.prepend("/").prepend(cloudFrontHost) + ) + ) + .from(liveTag) + .where(where) + .orderBy(liveTag.orders.asc()) + .fetch() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/tag/LiveTagService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/tag/LiveTagService.kt new file mode 100644 index 0000000..6237a01 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/tag/LiveTagService.kt @@ -0,0 +1,99 @@ +package kr.co.vividnext.sodalive.live.tag + +import com.amazonaws.services.s3.model.ObjectMetadata +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.Member +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 LiveTagService( + private val repository: LiveTagRepository, + + private val objectMapper: ObjectMapper, + private val s3Uploader: S3Uploader, + + @Value("\${cloud.aws.s3.bucket}") + private val coverImageBucket: String, + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) { + @Transactional + fun enrollmentLiveTag(image: MultipartFile, requestString: String) { + val request = objectMapper.readValue(requestString, CreateLiveTagRequest::class.java) + tagExistCheck(request) + + val tag = repository.save(LiveTag(request.tag)) + + val metadata = ObjectMetadata() + metadata.contentLength = image.size + + val tagImageFileName = generateFileName(prefix = "${tag.id}-") + val tagImagePath = s3Uploader.upload( + inputStream = image.inputStream, + bucket = coverImageBucket, + filePath = "live_cover/${tag.id}/$tagImageFileName", + metadata = metadata + ) + + tag.image = tagImagePath + } + + @Transactional + fun deleteTag(id: Long) { + val tag = repository.findByIdOrNull(id) + ?: throw SodaException("잘못된 요청입니다.") + + tag.tag = "${tag.tag}_deleted" + tag.isActive = false + } + + @Transactional + fun modifyTag(id: Long, image: MultipartFile?, requestString: String) { + val tag = repository.findByIdOrNull(id) + ?: throw SodaException("잘못된 요청입니다.") + + val request = objectMapper.readValue(requestString, CreateLiveTagRequest::class.java) + tag.tag = request.tag + + if (image != null) { + val metadata = ObjectMetadata() + metadata.contentLength = image.size + + val tagImageFileName = generateFileName(prefix = "${tag.id}-") + val tagImagePath = s3Uploader.upload( + inputStream = image.inputStream, + bucket = coverImageBucket, + filePath = "live_cover/${tag.id}/$tagImageFileName", + metadata = metadata + ) + + tag.image = tagImagePath + } + } + + @Transactional + fun updateTagOrders(ids: List) { + for (index in ids.indices) { + val tag = repository.findByIdOrNull(ids[index]) + + if (tag != null) { + tag.orders = index + 1 + } + } + } + + fun getTags(member: Member): List { + return repository.getTags(role = member.role, isAdult = member.auth != null, cloudFrontHost = cloudFrontHost) + } + + fun tagExistCheck(request: CreateLiveTagRequest) { + repository.findByTag(request.tag)?.let { throw SodaException("이미 등록된 태그 입니다.") } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt new file mode 100644 index 0000000..a6b5c49 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt @@ -0,0 +1,115 @@ +package kr.co.vividnext.sodalive.member + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.member.auth.Auth +import kr.co.vividnext.sodalive.member.following.CreatorFollowing +import kr.co.vividnext.sodalive.member.notification.MemberNotification +import kr.co.vividnext.sodalive.member.stipulation.StipulationAgree +import kr.co.vividnext.sodalive.member.tag.MemberCreatorTag +import javax.persistence.CascadeType +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated +import javax.persistence.FetchType +import javax.persistence.OneToMany +import javax.persistence.OneToOne + +@Entity +data class Member( + val email: String, + var password: String, + var nickname: String, + var profileImage: String? = null, + + @Enumerated(value = EnumType.STRING) + var gender: Gender = Gender.NONE, + + @Enumerated(value = EnumType.STRING) + var role: MemberRole = MemberRole.USER, + + var isActive: Boolean = true, + + var container: String = "web" +) : BaseEntity() { + @OneToMany(mappedBy = "member", cascade = [CascadeType.ALL]) + val stipulationAgrees: MutableList = mutableListOf() + + @OneToMany(mappedBy = "member", cascade = [CascadeType.ALL], orphanRemoval = true) + var tags: MutableList = mutableListOf() + + @OneToMany(mappedBy = "creator") + var follower: MutableList = mutableListOf() + + @OneToMany(mappedBy = "member", cascade = [CascadeType.ALL]) + val signOutReasons: MutableList = mutableListOf() + + @OneToOne(mappedBy = "member", fetch = FetchType.LAZY) + var notification: MemberNotification? = null + + @OneToOne(mappedBy = "member", fetch = FetchType.LAZY) + var auth: Auth? = null + + // 소개 + @Column(columnDefinition = "TEXT") + var introduce = "" + + // SNS + var instagramUrl = "" + var youtubeUrl = "" + var websiteUrl = "" + var blogUrl = "" + + var pushToken: String? = null + + // 화폐 + var pgChargeCan: Int = 0 + var pgRewardCan: Int = 0 + var googleChargeCan: Int = 0 + var googleRewardCan: Int = 0 + var appleChargeCan: Int = 0 + var appleRewardCan: Int = 0 + + fun getChargeCan(container: String): Int { + return when (container) { + "ios" -> appleChargeCan + pgChargeCan + "aos" -> googleChargeCan + pgChargeCan + else -> pgChargeCan + } + } + + fun getRewardCan(container: String): Int { + return when (container) { + "ios" -> appleRewardCan + pgRewardCan + "aos" -> googleRewardCan + pgRewardCan + else -> pgRewardCan + } + } + + fun charge(chargeCan: Int, rewardCan: Int, container: String) { + when (container) { + "ios" -> { + appleChargeCan = chargeCan + appleRewardCan = rewardCan + } + + "aos" -> { + googleChargeCan = chargeCan + googleRewardCan = rewardCan + } + + else -> { + pgChargeCan = chargeCan + pgRewardCan = rewardCan + } + } + } +} + +enum class Gender { + MALE, FEMALE, NONE +} + +enum class MemberRole { + ADMIN, BOT, USER, CREATOR, AGENT +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberAdapter.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberAdapter.kt new file mode 100644 index 0000000..ce42b37 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberAdapter.kt @@ -0,0 +1,10 @@ +package kr.co.vividnext.sodalive.member + +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.User + +class MemberAdapter(val member: Member) : User( + member.email, + member.password, + listOf(SimpleGrantedAuthority("ROLE_${member.role.name}")) +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt new file mode 100644 index 0000000..a515312 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt @@ -0,0 +1,154 @@ +package kr.co.vividnext.sodalive.member + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.block.MemberBlockRequest +import kr.co.vividnext.sodalive.member.following.CreatorFollowRequest +import kr.co.vividnext.sodalive.member.login.LoginRequest +import kr.co.vividnext.sodalive.member.notification.UpdateNotificationSettingRequest +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.security.core.userdetails.User +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.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RequestPart +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile + +@RestController +@RequestMapping("/member") +class MemberController(private val service: MemberService) { + @PostMapping("/signup") + fun signUp( + @RequestPart("profileImage", required = false) profileImage: MultipartFile? = null, + @RequestPart("request") requestString: String + ) = service.signUp(profileImage, requestString) + + @PostMapping("/login") + fun login(@RequestBody loginRequest: LoginRequest) = service.login(loginRequest) + + @PostMapping("/logout") + fun logout( + @RequestHeader("Authorization") token: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.logout(token.removePrefix("Bearer "), member.id!!)) + } + + @PostMapping("/logout/all") + fun logoutAll( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.logoutAll(member.id!!)) + } + + @GetMapping("/info") + fun getMemberInfo( + @RequestParam container: String?, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.getMemberInfo(member, container ?: "web")) + } + + @PostMapping("/notification") + fun updateNotificationSettings( + @RequestBody request: UpdateNotificationSettingRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.updateNotificationSettings(request, member)) + } + + @PutMapping("/push-token/update") + fun updatePushToken( + @RequestBody pushTokenUpdateRequest: PushTokenUpdateRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok( + service.updatePushToken( + memberId = member.id!!, + pushToken = pushTokenUpdateRequest.pushToken, + container = pushTokenUpdateRequest.container + ) + ) + } + + @GetMapping("/mypage") + fun getMyPage( + @RequestParam container: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.getMyPage(member, container)) + } + + @PostMapping("/creator/follow") + fun creatorFollow( + @RequestBody request: CreatorFollowRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.creatorFollow(creatorId = request.creatorId, memberId = member.id!!)) + } + + @PostMapping("/creator/unfollow") + fun creatorUnFollow( + @RequestBody request: CreatorFollowRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.creatorUnFollow(creatorId = request.creatorId, memberId = member.id!!)) + } + + @PostMapping("/block") + fun memberBlock( + @RequestBody request: MemberBlockRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.memberBlock(request = request, memberId = member.id!!)) + } + + @PostMapping("/unblock") + fun memberUnBlock( + @RequestBody request: MemberBlockRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.memberUnBlock(request = request, memberId = member.id!!)) + } + + @GetMapping("/search") + fun searchMember( + @RequestParam nickname: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.searchMember(nickname = nickname, memberId = member.id!!)) + } + + @PostMapping("/sign_out") + fun signOut( + @RequestBody signOutRequest: SignOutRequest, + @AuthenticationPrincipal user: User + ) = ApiResponse.ok(service.signOut(signOutRequest, user), "정상적으로 탈퇴 처리되었습니다.") +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt new file mode 100644 index 0000000..615774c --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt @@ -0,0 +1,213 @@ +package kr.co.vividnext.sodalive.member + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.fcm.GetMessageRecipientPushTokenResponse +import kr.co.vividnext.sodalive.fcm.QGetMessageRecipientPushTokenResponse +import kr.co.vividnext.sodalive.member.QMember.member +import kr.co.vividnext.sodalive.member.auth.QAuth.auth +import kr.co.vividnext.sodalive.member.block.BlockMemberRepository +import kr.co.vividnext.sodalive.member.following.QCreatorFollowing.creatorFollowing +import kr.co.vividnext.sodalive.member.notification.QMemberNotification.memberNotification +import kr.co.vividnext.sodalive.message.QMessage.message +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface MemberRepository : JpaRepository, MemberQueryRepository { + fun findByEmail(email: String): Member? + fun findByNickname(nickname: String): Member? +} + +interface MemberQueryRepository { + fun findByPushToken(pushToken: String): List + fun findByNicknameAndOtherCondition(nickname: String, memberId: Long): List + fun findCreatorByIdOrNull(memberId: Long): Member? + fun getAllRecipientPushTokens(isAuth: Boolean, container: String): List> + fun getCreateLiveRoomNotificationRecipientPushTokens( + creatorId: Long, + isAuth: Boolean, + container: String + ): List> + + fun getUploadContentNotificationRecipientPushTokens( + creatorId: Long, + isAuth: Boolean, + container: String + ): List> + + fun getMessageRecipientPushToken(messageId: Long): GetMessageRecipientPushTokenResponse + fun getIndividualRecipientPushTokens(recipients: List, isAuth: Boolean): Map>> +} + +@Repository +class MemberQueryRepositoryImpl( + private val queryFactory: JPAQueryFactory, + private val blockMemberRepository: BlockMemberRepository +) : MemberQueryRepository { + override fun findByPushToken(pushToken: String): List { + return queryFactory + .selectFrom(member) + .where(member.pushToken.eq(pushToken)) + .fetch() + } + + override fun findByNicknameAndOtherCondition(nickname: String, memberId: Long): List { + return queryFactory + .selectFrom(member) + .where( + member.nickname.containsIgnoreCase(nickname) + .and(member.id.ne(memberId)) + .and(member.role.ne(MemberRole.ADMIN)) + .and(member.role.ne(MemberRole.AGENT)) + ) + .fetch() + } + + override fun findCreatorByIdOrNull(memberId: Long): Member? { + return queryFactory + .selectFrom(member) + .where( + member.id.eq(memberId) + .and(member.role.eq(MemberRole.CREATOR)) + ) + .fetchFirst() + } + + override fun getAllRecipientPushTokens(isAuth: Boolean, container: String): List> { + var where = member.isActive.isTrue + .and(member.email.notIn("admin@sodalive.net")) + .and(member.container.eq(container)) + + if (isAuth) { + where = where.and(member.auth.isNotNull) + } + + return queryFactory + .select(member.pushToken) + .from(member) + .leftJoin(member.auth, auth) + .where(where) + .fetch() + .toSet() + .chunked(500) + } + + override fun getCreateLiveRoomNotificationRecipientPushTokens( + creatorId: Long, + isAuth: Boolean, + container: String + ): List> { + val member = QMember.member + val creator = QMember.member + + var where = creatorFollowing.isActive.isTrue + .and(creatorFollowing.creator.id.eq(creatorId)) + .and(creatorFollowing.member.notification.live.isTrue) + .and(creatorFollowing.member.container.eq(container)) + .and(creatorFollowing.member.email.notIn("admin@sodalive.net")) + .and( + creatorFollowing.member.id.notIn( + blockMemberRepository.getBlockedMemberList(creatorId) + ) + ) + + if (isAuth) { + where = where.and(member.auth.isNotNull) + } + + return queryFactory + .select(creatorFollowing.member.pushToken) + .from(creatorFollowing) + .innerJoin(creatorFollowing.creator, creator) + .innerJoin(creatorFollowing.member, member) + .innerJoin(member.notification, memberNotification) + .leftJoin(member.auth, auth) + .where(where) + .fetch() + .toSet() + .chunked(500) + } + + override fun getUploadContentNotificationRecipientPushTokens( + creatorId: Long, + isAuth: Boolean, + container: String + ): List> { + val member = QMember.member + val creator = QMember.member + + var where = creatorFollowing.isActive.isTrue + .and(creatorFollowing.creator.id.eq(creatorId)) + .and(creatorFollowing.member.email.notIn("admin@sodalive.net")) + .and(creatorFollowing.member.container.eq(container)) + .and(creatorFollowing.member.notification.uploadContent.isTrue) + .and( + creatorFollowing.member.id.notIn( + blockMemberRepository.getBlockedMemberList(creatorId) + ) + ) + + if (isAuth) { + where = where.and(member.auth.isNotNull) + } + + return queryFactory + .select(creatorFollowing.member.pushToken) + .from(creatorFollowing) + .innerJoin(creatorFollowing.creator, creator) + .innerJoin(creatorFollowing.member, member) + .innerJoin(member.notification, memberNotification) + .leftJoin(member.auth, auth) + .where(where) + .fetch() + .toSet() + .chunked(500) + } + + override fun getMessageRecipientPushToken(messageId: Long): GetMessageRecipientPushTokenResponse { + return queryFactory + .select( + QGetMessageRecipientPushTokenResponse( + member.pushToken, + member.container + ) + ) + .from(message) + .innerJoin(message.recipient, member) + .where(message.id.eq(messageId)) + .fetchFirst() + } + + override fun getIndividualRecipientPushTokens( + recipients: List, + isAuth: Boolean + ): Map>> { + var where = member.isActive.isTrue + .and(member.email.notIn("admin@sodalive.net")) + .and(member.id.`in`(*recipients.toTypedArray())) + + if (isAuth) { + where = where.and(member.auth.isNotNull) + } + + val aosPushTokens = queryFactory + .select(member.pushToken) + .from(member) + .leftJoin(member.auth, auth) + .where(where.and(member.container.eq("aos"))) + .fetch() + .toSet() + .chunked(500) + + val iosPushTokens = queryFactory + .select(member.pushToken) + .from(member) + .leftJoin(member.auth, auth) + .where(where.and(member.container.eq("ios"))) + .fetch() + .toSet() + .chunked(500) + + return mapOf("aos" to aosPushTokens, "ios" to iosPushTokens) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt new file mode 100644 index 0000000..a916159 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt @@ -0,0 +1,412 @@ +package kr.co.vividnext.sodalive.member + +import com.amazonaws.services.s3.model.ObjectMetadata +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.jwt.TokenProvider +import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser +import kr.co.vividnext.sodalive.member.block.BlockMember +import kr.co.vividnext.sodalive.member.block.BlockMemberRepository +import kr.co.vividnext.sodalive.member.block.MemberBlockRequest +import kr.co.vividnext.sodalive.member.following.CreatorFollowing +import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository +import kr.co.vividnext.sodalive.member.info.GetMemberInfoResponse +import kr.co.vividnext.sodalive.member.login.LoginRequest +import kr.co.vividnext.sodalive.member.login.LoginResponse +import kr.co.vividnext.sodalive.member.myPage.MyPageResponse +import kr.co.vividnext.sodalive.member.notification.MemberNotificationService +import kr.co.vividnext.sodalive.member.notification.UpdateNotificationSettingRequest +import kr.co.vividnext.sodalive.member.signUp.SignUpRequest +import kr.co.vividnext.sodalive.member.signUp.SignUpValidator +import kr.co.vividnext.sodalive.member.stipulation.Stipulation +import kr.co.vividnext.sodalive.member.stipulation.StipulationAgree +import kr.co.vividnext.sodalive.member.stipulation.StipulationAgreeRepository +import kr.co.vividnext.sodalive.member.stipulation.StipulationIds +import kr.co.vividnext.sodalive.member.stipulation.StipulationRepository +import kr.co.vividnext.sodalive.member.token.MemberTokenRepository +import kr.co.vividnext.sodalive.utils.generateFileName +import org.springframework.beans.factory.annotation.Value +import org.springframework.data.repository.findByIdOrNull +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.core.userdetails.UsernameNotFoundException +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.multipart.MultipartFile +import java.util.concurrent.locks.ReentrantReadWriteLock +import kotlin.concurrent.write + +@Service +@Transactional(readOnly = true) +class MemberService( + private val repository: MemberRepository, + private val tokenRepository: MemberTokenRepository, + private val stipulationRepository: StipulationRepository, + private val stipulationAgreeRepository: StipulationAgreeRepository, + private val creatorFollowingRepository: CreatorFollowingRepository, + private val blockMemberRepository: BlockMemberRepository, + private val signOutRepository: SignOutRepository, + + private val memberNotificationService: MemberNotificationService, + + private val s3Uploader: S3Uploader, + private val validator: SignUpValidator, + private val tokenProvider: TokenProvider, + private val passwordEncoder: PasswordEncoder, + private val authenticationManagerBuilder: AuthenticationManagerBuilder, + + private val objectMapper: ObjectMapper, + + @Value("\${cloud.aws.s3.bucket}") + private val s3Bucket: String, + + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) : UserDetailsService { + + private val tokenLocks: MutableMap = mutableMapOf() + + @Transactional + fun signUp( + profileImage: MultipartFile?, + requestString: String + ): ApiResponse { + val stipulationTermsOfService = stipulationRepository.findByIdOrNull(StipulationIds.TERMS_OF_SERVICE_ID) + ?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.") + + val stipulationPrivacyPolicy = stipulationRepository.findByIdOrNull(StipulationIds.PRIVACY_POLICY_ID) + ?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.") + + val request = objectMapper.readValue(requestString, SignUpRequest::class.java) + if (!request.isAgreePrivacyPolicy || !request.isAgreeTermsOfService) { + throw SodaException("약관에 동의하셔야 회원가입이 가능합니다.") + } + + validatePassword(request.password) + duplicateCheckEmail(request.email) + duplicateCheckNickname(request.nickname) + + val member = createMember(request) + member.profileImage = uploadProfileImage(profileImage = profileImage, memberId = member.id!!) + agreeTermsOfServiceAndPrivacyPolicy(member, stipulationTermsOfService, stipulationPrivacyPolicy) + + return ApiResponse.ok(message = "회원가입을 축하드립니다.", data = login(request.email, request.password)) + } + + fun login(request: LoginRequest): ApiResponse { + return ApiResponse.ok( + message = "로그인 되었습니다.", + data = login(request.email, request.password, request.isAdmin, request.isCreator) + ) + } + + fun getMemberInfo(member: Member, container: String): GetMemberInfoResponse { + return GetMemberInfoResponse( + can = member.getChargeCan(container) + member.getRewardCan(container), + isAuth = member.auth != null, + role = member.role, + messageNotice = member.notification?.message, + followingChannelLiveNotice = member.notification?.live, + followingChannelUploadContentNotice = member.notification?.uploadContent + ) + } + + @Transactional + fun updateNotificationSettings(request: UpdateNotificationSettingRequest, member: Member) { + memberNotificationService.updateNotification( + live = request.live, + uploadContent = request.uploadContent, + message = request.message, + member = member + ) + } + + @Transactional + fun updatePushToken(memberId: Long, pushToken: String, container: String) { + val existsHavePushTokenMemberList = repository.findByPushToken(pushToken = pushToken) + for (existsHavePushTokenMember in existsHavePushTokenMemberList) { + existsHavePushTokenMember.pushToken = null + } + + val member = repository.findByIdOrNull(id = memberId) + ?: throw SodaException("로그인 정보를 확인해주세요.") + + member.pushToken = pushToken + member.container = container + } + + fun getMyPage(member: Member, container: String): MyPageResponse { + return MyPageResponse( + nickname = member.nickname, + profileUrl = if (member.profileImage != null) { + "$cloudFrontHost/${member.profileImage}" + } else { + "$cloudFrontHost/profile/default-profile.png" + }, + chargeCan = member.getChargeCan(container = container), + rewardCan = member.getRewardCan(container = container), + youtubeUrl = member.youtubeUrl, + instagramUrl = member.instagramUrl, + websiteUrl = member.websiteUrl, + blogUrl = member.blogUrl, + liveReservationCount = 0, + isAuth = member.auth != null + ) + } + + private fun login( + email: String, + password: String, + isAdmin: Boolean = false, + isCreator: Boolean = false + ): LoginResponse { + val member = repository.findByEmail(email = email) ?: throw SodaException("로그인 정보를 확인해주세요.") + if (!member.isActive) { + throw SodaException("탈퇴한 계정입니다.\n고객센터로 문의해 주시기 바랍니다.") + } + + if (isCreator && member.role != MemberRole.CREATOR) { + throw SodaException("로그인 정보를 확인해주세요.") + } + + if (isAdmin && member.role != MemberRole.ADMIN) { + throw SodaException("로그인 정보를 확인해주세요.") + } + + val authenticationToken = UsernamePasswordAuthenticationToken(email, password) + val authentication = authenticationManagerBuilder.`object`.authenticate(authenticationToken) + SecurityContextHolder.getContext().authentication = authentication + + val jwt = tokenProvider.createToken( + authentication = authentication, + memberId = member.id!! + ) + + return LoginResponse( + userId = member.id!!, + token = jwt, + nickname = member.nickname, + email = member.email, + profileImage = if (member.profileImage != null) { + "$cloudFrontHost/${member.profileImage}" + } else { + "$cloudFrontHost/profile/default-profile.png" + } + ) + } + + private fun uploadProfileImage(profileImage: MultipartFile?, memberId: Long): String { + return if (profileImage != null) { + val metadata = ObjectMetadata() + metadata.contentLength = profileImage.size + + s3Uploader.upload( + inputStream = profileImage.inputStream, + bucket = s3Bucket, + filePath = "profile/$memberId/${generateFileName(prefix = "$memberId-profile")}", + metadata = metadata + ) + } else { + "profile/default-profile.png" + } + } + + private fun agreeTermsOfServiceAndPrivacyPolicy( + member: Member, + stipulationTermsOfService: Stipulation, + stipulationPrivacyPolicy: Stipulation + ) { + val termsOfServiceAgree = StipulationAgree(true) + termsOfServiceAgree.member = member + termsOfServiceAgree.stipulation = stipulationTermsOfService + stipulationAgreeRepository.save(termsOfServiceAgree) + + val privacyPolicyAgree = StipulationAgree(true) + privacyPolicyAgree.member = member + privacyPolicyAgree.stipulation = stipulationPrivacyPolicy + stipulationAgreeRepository.save(privacyPolicyAgree) + } + + private fun createMember(request: SignUpRequest): Member { + val member = Member( + email = request.email, + password = passwordEncoder.encode(request.password), + nickname = request.nickname, + gender = request.gender, + container = request.container + ) + + return repository.save(member) + } + + private fun validatePassword(password: String) { + val passwordValidationMessage = validator.passwordValidation(password) + if (passwordValidationMessage.trim().isNotEmpty()) { + throw SodaException(passwordValidationMessage) + } + } + + fun duplicateCheckEmail(email: String): ApiResponse { + validateEmail(email) + repository.findByEmail(email)?.let { throw SodaException("이미 사용중인 이메일 입니다.", "email") } + return ApiResponse.ok(message = "사용 가능한 이메일 입니다.") + } + + private fun validateEmail(email: String) { + val emailValidationMessage = validator.emailValidation(email) + if (emailValidationMessage.trim().isNotEmpty()) { + throw SodaException(emailValidationMessage, "email") + } + } + + fun duplicateCheckNickname(nickname: String): ApiResponse { + validateNickname(nickname) + repository.findByNickname(nickname)?.let { throw SodaException("이미 사용중인 닉네임 입니다.", "nickname") } + return ApiResponse.ok(message = "사용 가능한 닉네임 입니다.") + } + + private fun validateNickname(nickname: String) { + val nicknameValidationMessage = validator.nicknameValidation(nickname) + if (nicknameValidationMessage.trim().isNotEmpty()) { + throw SodaException(nicknameValidationMessage, "nickname") + } + } + + override fun loadUserByUsername(username: String): UserDetails { + val member = repository.findByEmail(email = username) + ?: throw UsernameNotFoundException(username) + + return MemberAdapter(member) + } + + @Transactional + fun creatorFollow(creatorId: Long, memberId: Long) { + val creatorFollowing = creatorFollowingRepository.findByCreatorIdAndMemberId( + creatorId = creatorId, + memberId = memberId + ) + + if (creatorFollowing == null) { + val creator = repository.findByIdOrNull(creatorId) ?: throw SodaException("크리에이터 정보를 확인해주세요.") + val member = repository.findByIdOrNull(memberId) ?: throw SodaException("로그인 정보를 확인해주세요.") + creatorFollowingRepository.save(CreatorFollowing(creator = creator, member = member)) + } else { + creatorFollowing.isActive = true + } + } + + @Transactional + fun creatorUnFollow(creatorId: Long, memberId: Long) { + val creatorFollowing = creatorFollowingRepository.findByCreatorIdAndMemberId( + creatorId = creatorId, + memberId = memberId + ) + + if (creatorFollowing != null) { + creatorFollowing.isActive = false + } + } + + fun memberBlock(request: MemberBlockRequest, memberId: Long) { + var blockMember = blockMemberRepository.getBlockAccount( + blockedMemberId = request.blockMemberId, + memberId = memberId + ) + + if (blockMember == null) { + blockMember = BlockMember( + blockedMemberId = request.blockMemberId, + memberId = memberId + ) + + blockMemberRepository.save(blockMember) + } else { + blockMember.isActive = true + } + } + + fun memberUnBlock(request: MemberBlockRequest, memberId: Long) { + val blockMember = blockMemberRepository.getBlockAccount( + blockedMemberId = request.blockMemberId, + memberId = memberId + ) + + if (blockMember != null) { + blockMember.isActive = true + } + } + + fun isBlocked(blockedMemberId: Long, memberId: Long) = blockMemberRepository.isBlocked(blockedMemberId, memberId) + + fun searchMember(nickname: String, memberId: Long): List { + if (nickname.length < 2) { + throw SodaException("두 글자 이상 입력 하셔야 합니다.") + } + + return repository.findByNicknameAndOtherCondition(nickname, memberId) + .asSequence() + .filter { !blockMemberRepository.isBlocked(blockedMemberId = memberId, memberId = it.id!!) } + .map { + GetRoomDetailUser(it, cloudFrontHost) + } + .toList() + } + + @Transactional + fun logout(token: String, memberId: Long) { + val member = repository.findByIdOrNull(memberId) + ?: throw SodaException("로그인 정보를 확인해주세요.") + + member.pushToken = null + + val lock = getOrCreateLock(memberId = memberId) + lock.write { + val memberToken = tokenRepository.findByIdOrNull(memberId) + ?: throw SodaException("로그인 정보를 확인해주세요.") + + memberToken.tokenSet.remove(token) + tokenRepository.save(memberToken) + } + } + + @Transactional + fun logoutAll(memberId: Long) { + val member = repository.findByIdOrNull(memberId) + ?: throw SodaException("로그인 정보를 확인해주세요.") + + member.pushToken = null + + val lock = getOrCreateLock(memberId = memberId) + lock.write { tokenRepository.deleteById(memberId) } + } + + @Transactional + fun signOut(signOutRequest: SignOutRequest, user: User) { + val member = repository.findByEmail(user.username) ?: throw SodaException("로그인 정보를 확인해주세요.") + if (!passwordEncoder.matches(signOutRequest.password, member.password)) { + throw SodaException("비밀번호가 일치하지 않습니다.") + } + + if (signOutRequest.reason.isBlank()) { + throw SodaException("탈퇴하려는 이유를 입력해 주세요.") + } + + logoutAll(memberId = member.id!!) + member.isActive = false + + val signOut = SignOut(reason = signOutRequest.reason) + signOut.member = member + signOutRepository.save(signOut) + } + + private fun getOrCreateLock(memberId: Long): ReentrantReadWriteLock { + return tokenLocks.computeIfAbsent(memberId) { ReentrantReadWriteLock() } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/PushTokenUpdateRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/PushTokenUpdateRequest.kt new file mode 100644 index 0000000..a379f5f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/PushTokenUpdateRequest.kt @@ -0,0 +1,3 @@ +package kr.co.vividnext.sodalive.member + +data class PushTokenUpdateRequest(val pushToken: String, val container: String) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/SignOut.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/SignOut.kt new file mode 100644 index 0000000..5447060 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/SignOut.kt @@ -0,0 +1,22 @@ +package kr.co.vividnext.sodalive.member + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne + +@Entity +data class SignOut( + @Column(columnDefinition = "TEXT", nullable = false) + val reason: String +) : BaseEntity() { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + var member: Member? = null + set(value) { + value?.signOutReasons?.add(this) + field = value + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/SignOutRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/SignOutRepository.kt new file mode 100644 index 0000000..c81dc61 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/SignOutRepository.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.member + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface SignOutRepository : JpaRepository diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/SignOutRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/SignOutRequest.kt new file mode 100644 index 0000000..208323d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/SignOutRequest.kt @@ -0,0 +1,6 @@ +package kr.co.vividnext.sodalive.member + +data class SignOutRequest( + val reason: String, + val password: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/Auth.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/Auth.kt new file mode 100644 index 0000000..1c66dfa --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/Auth.kt @@ -0,0 +1,31 @@ +package kr.co.vividnext.sodalive.member.auth + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.member.Member +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.OneToOne + +@Entity +data class Auth( + @Column(nullable = false) + val name: String, + @Column(nullable = false) + val birth: String, + @Column(columnDefinition = "TEXT", nullable = false) + val uniqueCi: String, + @Column(columnDefinition = "TEXT", nullable = false) + val di: String, + @Column(nullable = false) + val gender: Int +) : BaseEntity() { + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + var member: Member? = null + set(value) { + value?.auth = this + field = value + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthController.kt new file mode 100644 index 0000000..764a24e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthController.kt @@ -0,0 +1,24 @@ +package kr.co.vividnext.sodalive.member.auth + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import org.springframework.security.core.annotation.AuthenticationPrincipal +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("/auth") +class AuthController(private val service: AuthService) { + @PostMapping + fun authVerify( + @RequestBody request: AuthVerifyRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.verify(member, request)) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthRepository.kt new file mode 100644 index 0000000..954dc91 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthRepository.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.member.auth + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface AuthRepository : JpaRepository diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthService.kt new file mode 100644 index 0000000..76af74e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthService.kt @@ -0,0 +1,62 @@ +package kr.co.vividnext.sodalive.member.auth + +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.bootpay.Bootpay +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate + +@Service +@Transactional(readOnly = true) +class AuthService( + private val repository: AuthRepository, + private val objectMapper: ObjectMapper, + + @Value("\${bootpay.application-id}") + private val bootpayApplicationId: String, + @Value("\${bootpay.private-key}") + private val bootpayPrivateKey: String +) { + @Transactional + fun verify(member: Member, request: AuthVerifyRequest) { + val bootpay = Bootpay(bootpayApplicationId, bootpayPrivateKey) + try { + val token = bootpay.accessToken + if (token["error_code"] != null) throw SodaException("인증정보에 오류가 있습니다.\n다시 시도해 주세요.") + + val res = bootpay.certificate(request.receiptId) + val certificateResult = objectMapper.convertValue(res, AuthCertificateResult::class.java) + + if ( + certificateResult.status == 12 && + certificateResult.statusLocale == "본인인증완료" && + certificateResult.receiptId == request.receiptId + ) { + val certificate = certificateResult.authenticateData + val nowYear = LocalDate.now().year + val certificateYear = certificate.birth.substring(0, 4).toInt() + if (nowYear - certificateYear >= 19) { + val auth = Auth( + name = certificate.name, + birth = certificate.birth, + uniqueCi = certificate.unique, + di = certificate.di, + gender = certificate.gender + ) + auth.member = member + + repository.save(auth) + } else { + throw SodaException("19세 미만 인증 오류") + } + } else { + throw SodaException("인증정보에 오류가 있습니다.\n다시 시도해 주세요.") + } + } catch (e: Exception) { + throw SodaException("인증정보에 오류가 있습니다.\n다시 시도해 주세요.") + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthVerifyRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthVerifyRequest.kt new file mode 100644 index 0000000..ea9590e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthVerifyRequest.kt @@ -0,0 +1,29 @@ +package kr.co.vividnext.sodalive.member.auth + +import com.fasterxml.jackson.annotation.JsonProperty + +data class AuthVerifyRequest( + @JsonProperty("receipt_id") + val receiptId: String, + @JsonProperty("version") + val version: String? +) + +data class AuthVerifyCertificate( + val name: String, + val birth: String, + val unique: String, + val di: String, + val gender: Int +) + +data class AuthCertificateResult( + @JsonProperty("receipt_id") + val receiptId: String, + @JsonProperty("status") + val status: Int, + @JsonProperty("status_locale") + val statusLocale: String, + @JsonProperty("authenticate_data") + val authenticateData: AuthVerifyCertificate +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/block/BlockMember.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/block/BlockMember.kt new file mode 100644 index 0000000..57811cd --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/block/BlockMember.kt @@ -0,0 +1,12 @@ +package kr.co.vividnext.sodalive.member.block + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Entity + +@Entity +data class BlockMember( + val blockedMemberId: Long, + val memberId: Long +) : BaseEntity() { + var isActive: Boolean = true +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/block/BlockMemberRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/block/BlockMemberRepository.kt new file mode 100644 index 0000000..f81572d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/block/BlockMemberRepository.kt @@ -0,0 +1,54 @@ +package kr.co.vividnext.sodalive.member.block + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.member.block.QBlockMember.blockMember +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface BlockMemberRepository : JpaRepository, BlockMemberQueryRepository + +interface BlockMemberQueryRepository { + fun getBlockAccount(blockedMemberId: Long, memberId: Long): BlockMember? + fun isBlocked(blockedMemberId: Long, memberId: Long): Boolean + fun getBlockedMemberList(creatorId: Long): List +} + +@Repository +class BlockMemberQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : BlockMemberQueryRepository { + override fun getBlockAccount(blockedMemberId: Long, memberId: Long): BlockMember? { + return queryFactory + .selectFrom(blockMember) + .where( + blockMember.blockedMemberId.eq(blockedMemberId) + .and(blockMember.memberId.eq(memberId)) + ) + .orderBy(blockMember.id.desc()) + .fetchFirst() + } + + override fun isBlocked(blockedMemberId: Long, memberId: Long): Boolean { + val blockedAccount = queryFactory + .select(blockMember.id) + .from(blockMember) + .where( + blockMember.memberId.eq(memberId) + .and(blockMember.blockedMemberId.eq(blockedMemberId)) + .and(blockMember.isActive.isTrue) + ) + .fetchOne() + + return blockedAccount != null + } + + override fun getBlockedMemberList(creatorId: Long): List { + return queryFactory + .select(blockMember.blockedMemberId) + .from(blockMember) + .where( + blockMember.memberId.eq(creatorId) + .and(blockMember.isActive.isTrue) + ) + .fetch() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/block/MemberBlockRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/block/MemberBlockRequest.kt new file mode 100644 index 0000000..1fddf16 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/block/MemberBlockRequest.kt @@ -0,0 +1,3 @@ +package kr.co.vividnext.sodalive.member.block + +data class MemberBlockRequest(val blockMemberId: Long) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowRequest.kt new file mode 100644 index 0000000..453004b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowRequest.kt @@ -0,0 +1,3 @@ +package kr.co.vividnext.sodalive.member.following + +data class CreatorFollowRequest(val creatorId: Long) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowing.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowing.kt new file mode 100644 index 0000000..c0a3044 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowing.kt @@ -0,0 +1,23 @@ +package kr.co.vividnext.sodalive.member.following + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.member.Member +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne + +@Entity +class CreatorFollowing( + // 유저가 알림받기 한 크리에이터 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "creator_id", nullable = false) + var creator: Member, + + // 크리에이터를 알림받기 한 유저 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + var member: Member, + + var isActive: Boolean = true +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowingRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowingRepository.kt new file mode 100644 index 0000000..d74b551 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowingRepository.kt @@ -0,0 +1,28 @@ +package kr.co.vividnext.sodalive.member.following + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.member.following.QCreatorFollowing.creatorFollowing +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface CreatorFollowingRepository : JpaRepository, CreatorFollowingQueryRepository + +interface CreatorFollowingQueryRepository { + fun findByCreatorIdAndMemberId(creatorId: Long, memberId: Long): CreatorFollowing? +} + +@Repository +class CreatorFollowingQueryRepositoryImpl( + private val queryFactory: JPAQueryFactory +) : CreatorFollowingQueryRepository { + override fun findByCreatorIdAndMemberId(creatorId: Long, memberId: Long): CreatorFollowing? { + return queryFactory + .selectFrom(creatorFollowing) + .where( + creatorFollowing.creator.id.eq(creatorId) + .and(creatorFollowing.member.id.eq(memberId)) + ) + .fetchFirst() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/info/GetMemberInfoResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/info/GetMemberInfoResponse.kt new file mode 100644 index 0000000..2db88e6 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/info/GetMemberInfoResponse.kt @@ -0,0 +1,12 @@ +package kr.co.vividnext.sodalive.member.info + +import kr.co.vividnext.sodalive.member.MemberRole + +data class GetMemberInfoResponse( + val can: Int, + val isAuth: Boolean, + val role: MemberRole, + val messageNotice: Boolean?, + val followingChannelLiveNotice: Boolean?, + val followingChannelUploadContentNotice: Boolean? +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/login/LoginRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/login/LoginRequest.kt new file mode 100644 index 0000000..f49585f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/login/LoginRequest.kt @@ -0,0 +1,8 @@ +package kr.co.vividnext.sodalive.member.login + +data class LoginRequest( + val email: String, + val password: String, + val isAdmin: Boolean = false, + val isCreator: Boolean = false +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/login/LoginResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/login/LoginResponse.kt new file mode 100644 index 0000000..667f06d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/login/LoginResponse.kt @@ -0,0 +1,9 @@ +package kr.co.vividnext.sodalive.member.login + +data class LoginResponse( + val userId: Long, + val token: String, + val nickname: String, + val email: String, + val profileImage: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/myPage/MyPageResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/myPage/MyPageResponse.kt new file mode 100644 index 0000000..d357f34 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/myPage/MyPageResponse.kt @@ -0,0 +1,14 @@ +package kr.co.vividnext.sodalive.member.myPage + +data class MyPageResponse( + val nickname: String, + val profileUrl: String, + val chargeCan: Int, + val rewardCan: Int, + val youtubeUrl: String?, + val instagramUrl: String?, + val websiteUrl: String? = null, + val blogUrl: String? = null, + val liveReservationCount: Int, + val isAuth: Boolean +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/notification/MemberNotification.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/notification/MemberNotification.kt new file mode 100644 index 0000000..4c3007a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/notification/MemberNotification.kt @@ -0,0 +1,29 @@ +package kr.co.vividnext.sodalive.member.notification + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.member.Member +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.OneToOne + +@Entity +data class MemberNotification( + @Column(nullable = false) + var uploadContent: Boolean? = true, + + @Column(nullable = false) + var live: Boolean? = true, + + @Column(nullable = false) + var message: Boolean? = true +) : BaseEntity() { + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + var member: Member? = null + set(value) { + value?.notification = this + field = value + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/notification/MemberNotificationRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/notification/MemberNotificationRepository.kt new file mode 100644 index 0000000..6d461d9 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/notification/MemberNotificationRepository.kt @@ -0,0 +1,25 @@ +package kr.co.vividnext.sodalive.member.notification + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.member.notification.QMemberNotification.memberNotification +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface MemberNotificationRepository : JpaRepository, MemberNotificationQueryRepository + +interface MemberNotificationQueryRepository { + fun getMemberNotification(memberId: Long): MemberNotification? +} + +@Repository +class MemberNotificationQueryRepositoryImpl( + private val queryFactory: JPAQueryFactory +) : MemberNotificationQueryRepository { + override fun getMemberNotification(memberId: Long): MemberNotification? { + return queryFactory + .selectFrom(memberNotification) + .where(memberNotification.member.id.eq(memberId)) + .fetchFirst() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/notification/MemberNotificationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/notification/MemberNotificationService.kt new file mode 100644 index 0000000..d46fc53 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/notification/MemberNotificationService.kt @@ -0,0 +1,27 @@ +package kr.co.vividnext.sodalive.member.notification + +import kr.co.vividnext.sodalive.member.Member +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class MemberNotificationService(private val repository: MemberNotificationRepository) { + fun updateNotification( + live: Boolean? = null, + uploadContent: Boolean? = null, + message: Boolean? = null, + member: Member + ) { + var notification = repository.getMemberNotification(memberId = member.id!!) + if (notification == null) { + notification = MemberNotification(uploadContent, live, message) + notification.member = member + repository.save(notification) + } else { + if (live != null) notification.live = live + if (message != null) notification.message = message + if (uploadContent != null) notification.uploadContent = uploadContent + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/notification/UpdateNotificationSettingRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/notification/UpdateNotificationSettingRequest.kt new file mode 100644 index 0000000..e9bc210 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/notification/UpdateNotificationSettingRequest.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.member.notification + +data class UpdateNotificationSettingRequest( + val live: Boolean?, + val message: Boolean?, + val uploadContent: Boolean? +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/signUp/SignUpRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/signUp/SignUpRequest.kt new file mode 100644 index 0000000..1d962ab --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/signUp/SignUpRequest.kt @@ -0,0 +1,13 @@ +package kr.co.vividnext.sodalive.member.signUp + +import kr.co.vividnext.sodalive.member.Gender + +data class SignUpRequest( + val email: String, + val password: String, + val nickname: String, + val gender: Gender, + val isAgreeTermsOfService: Boolean, + val isAgreePrivacyPolicy: Boolean, + val container: String = "api" +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/signUp/SignUpValidator.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/signUp/SignUpValidator.kt new file mode 100644 index 0000000..38cbebb --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/signUp/SignUpValidator.kt @@ -0,0 +1,40 @@ +package kr.co.vividnext.sodalive.member.signUp + +import org.springframework.stereotype.Component + +@Component +class SignUpValidator { + fun emailValidation(email: String): String { + val isNotValidEmail = "^[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,6}$" + .toRegex(RegexOption.IGNORE_CASE) + .matches(email) + .not() + + if (isNotValidEmail) { + return "올바른 이메일을 입력해 주세요" + } + + return "" + } + + fun nicknameValidation(nickname: String): String { + if (nickname.length < 2) { + return "닉네임은 2자 이상 입력해 주세요." + } + + return "" + } + + fun passwordValidation(password: String): String { + val isNotValidPassword = "^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d$@!%*#?&]{8,}$" + .toRegex() + .matches(password) + .not() + + if (isNotValidPassword) { + return "영문, 숫자 포함 8자 이상의 비밀번호를 입력해 주세요." + } + + return "" + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/stipulation/Stipulation.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/stipulation/Stipulation.kt new file mode 100644 index 0000000..e854861 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/stipulation/Stipulation.kt @@ -0,0 +1,14 @@ +package kr.co.vividnext.sodalive.member.stipulation + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Column +import javax.persistence.Entity + +@Entity +data class Stipulation( + @Column(nullable = false) + val title: String, + @Column(columnDefinition = "TEXT", nullable = false) + var description: String, + var isActive: Boolean = true +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/stipulation/StipulationAgree.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/stipulation/StipulationAgree.kt new file mode 100644 index 0000000..e5504e9 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/stipulation/StipulationAgree.kt @@ -0,0 +1,30 @@ +package kr.co.vividnext.sodalive.member.stipulation + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.member.Member +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne + +@Entity +data class StipulationAgree( + val isAgree: Boolean +) : BaseEntity() { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + var member: Member? = null + set(value) { + member?.stipulationAgrees?.add(this) + field = value + } + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "stipulation_id", nullable = false) + var stipulation: Stipulation? = null +} + +object StipulationIds { + const val TERMS_OF_SERVICE_ID = 1L + const val PRIVACY_POLICY_ID = 2L +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/stipulation/StipulationController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/stipulation/StipulationController.kt new file mode 100644 index 0000000..89163e7 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/stipulation/StipulationController.kt @@ -0,0 +1,33 @@ +package kr.co.vividnext.sodalive.member.stipulation + +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.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/stplat") +class StipulationController(private val service: StipulationService) { + @PostMapping + @PreAuthorize("hasRole('ADMIN')") + fun enrollment(@RequestBody request: StipulationDto) = ApiResponse.ok( + service.enrollment(request), + "등록되었습니다." + ) + + @PostMapping("/modify") + @PreAuthorize("hasRole('ADMIN')") + fun modify(@RequestBody request: StipulationModifyRequest) = ApiResponse.ok( + service.modify(request), + "수정되었습니다." + ) + + @GetMapping("/terms_of_service") + fun getTermsOfService() = ApiResponse.ok(service.getTermsOfService()) + + @GetMapping("/privacy_policy") + fun getPrivacyPolicy() = ApiResponse.ok(service.getPrivacyPolicy()) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/stipulation/StipulationDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/stipulation/StipulationDto.kt new file mode 100644 index 0000000..effec70 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/stipulation/StipulationDto.kt @@ -0,0 +1,11 @@ +package kr.co.vividnext.sodalive.member.stipulation + +data class StipulationDto( + val id: Long = 0, + val title: String, + val description: String +) { + constructor(stipulation: Stipulation) : this(stipulation.id!!, stipulation.title, stipulation.description) + + fun toEntity() = Stipulation(title, description) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/stipulation/StipulationModifyRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/stipulation/StipulationModifyRequest.kt new file mode 100644 index 0000000..9e49c05 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/stipulation/StipulationModifyRequest.kt @@ -0,0 +1,6 @@ +package kr.co.vividnext.sodalive.member.stipulation + +data class StipulationModifyRequest( + val id: Long, + val description: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/stipulation/StipulationRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/stipulation/StipulationRepository.kt new file mode 100644 index 0000000..54332b9 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/stipulation/StipulationRepository.kt @@ -0,0 +1,10 @@ +package kr.co.vividnext.sodalive.member.stipulation + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface StipulationRepository : JpaRepository + +@Repository +interface StipulationAgreeRepository : JpaRepository diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/stipulation/StipulationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/stipulation/StipulationService.kt new file mode 100644 index 0000000..617b071 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/stipulation/StipulationService.kt @@ -0,0 +1,38 @@ +package kr.co.vividnext.sodalive.member.stipulation + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.stipulation.StipulationIds.PRIVACY_POLICY_ID +import kr.co.vividnext.sodalive.member.stipulation.StipulationIds.TERMS_OF_SERVICE_ID +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class StipulationService(private val repository: StipulationRepository) { + fun enrollment(request: StipulationDto) { + repository.save(request.toEntity()) + } + + fun getTermsOfService(): StipulationDto { + val stipulation = repository.findByIdOrNull(TERMS_OF_SERVICE_ID) + ?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.") + + return StipulationDto(stipulation) + } + + fun getPrivacyPolicy(): StipulationDto { + val stipulation = repository.findByIdOrNull(PRIVACY_POLICY_ID) + ?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.") + + return StipulationDto(stipulation) + } + + @Transactional + fun modify(request: StipulationModifyRequest) { + val stipulation = repository.findByIdOrNull(request.id) + ?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.") + + stipulation.description = request.description + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/tag/CreatorTag.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/tag/CreatorTag.kt new file mode 100644 index 0000000..99e3dfc --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/tag/CreatorTag.kt @@ -0,0 +1,17 @@ +package kr.co.vividnext.sodalive.member.tag + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Column +import javax.persistence.Entity + +@Entity +data class CreatorTag( + @Column(unique = true, nullable = false) + var tag: String, + @Column(nullable = true) + var image: String? = null, + @Column(nullable = false) + var isActive: Boolean = true, + @Column(nullable = false) + var orders: Int = 1 +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/tag/GetMemberTagResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/tag/GetMemberTagResponse.kt new file mode 100644 index 0000000..a98882b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/tag/GetMemberTagResponse.kt @@ -0,0 +1,9 @@ +package kr.co.vividnext.sodalive.member.tag + +import com.querydsl.core.annotations.QueryProjection + +data class GetMemberTagResponse @QueryProjection constructor( + val id: Long, + val tag: String, + val image: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/tag/MemberCreatorTag.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/tag/MemberCreatorTag.kt new file mode 100644 index 0000000..ea0ecd3 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/tag/MemberCreatorTag.kt @@ -0,0 +1,19 @@ +package kr.co.vividnext.sodalive.member.tag + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.member.Member +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne + +@Entity +data class MemberCreatorTag( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + var member: Member, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "creator_tag_id", nullable = false) + var tag: CreatorTag +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/tag/MemberTagController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/tag/MemberTagController.kt new file mode 100644 index 0000000..ff4c7f5 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/tag/MemberTagController.kt @@ -0,0 +1,13 @@ +package kr.co.vividnext.sodalive.member.tag + +import kr.co.vividnext.sodalive.common.ApiResponse +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/member/tag") +class MemberTagController(private val service: MemberTagService) { + @GetMapping + fun getTags() = ApiResponse.ok(service.getTags()) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/tag/MemberTagRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/tag/MemberTagRepository.kt new file mode 100644 index 0000000..078a252 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/tag/MemberTagRepository.kt @@ -0,0 +1,36 @@ +package kr.co.vividnext.sodalive.member.tag + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.member.tag.QCreatorTag.creatorTag +import org.springframework.beans.factory.annotation.Value +import org.springframework.data.jpa.repository.JpaRepository + +interface MemberTagRepository : JpaRepository, MemberTagQueryRepository { + fun findByTag(tag: String): CreatorTag? +} + +interface MemberTagQueryRepository { + fun getTags(): List +} + +class MemberTagQueryRepositoryImpl( + private val queryFactory: JPAQueryFactory, + + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) : MemberTagQueryRepository { + override fun getTags(): List { + return queryFactory + .select( + QGetMemberTagResponse( + creatorTag.id, + creatorTag.tag, + creatorTag.image.prepend("/").prepend(cloudFrontHost) + ) + ) + .from(creatorTag) + .where(creatorTag.isActive.eq(true)) + .orderBy(creatorTag.orders.asc()) + .fetch() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/tag/MemberTagService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/tag/MemberTagService.kt new file mode 100644 index 0000000..72a51b3 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/tag/MemberTagService.kt @@ -0,0 +1,10 @@ +package kr.co.vividnext.sodalive.member.tag + +import org.springframework.stereotype.Service + +@Service +class MemberTagService(private val repository: MemberTagRepository) { + fun getTags(): List { + return repository.getTags() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/token/MemberToken.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/token/MemberToken.kt new file mode 100644 index 0000000..1af6273 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/token/MemberToken.kt @@ -0,0 +1,11 @@ +package kr.co.vividnext.sodalive.member.token + +import org.springframework.data.annotation.Id +import org.springframework.data.redis.core.RedisHash + +@RedisHash("MemberToken") +data class MemberToken( + @Id + val id: Long, + var tokenSet: MutableSet = mutableSetOf() +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/token/MemberTokenRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/token/MemberTokenRepository.kt new file mode 100644 index 0000000..29808a2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/token/MemberTokenRepository.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.member.token + +import org.springframework.data.repository.CrudRepository +import org.springframework.stereotype.Repository + +@Repository +interface MemberTokenRepository : CrudRepository diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/menu/GetMenuResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/menu/GetMenuResponse.kt new file mode 100644 index 0000000..254ac78 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/menu/GetMenuResponse.kt @@ -0,0 +1,12 @@ +package kr.co.vividnext.sodalive.menu + +import com.fasterxml.jackson.annotation.JsonInclude +import com.querydsl.core.annotations.QueryProjection + +data class GetMenuResponse @QueryProjection constructor( + val title: String, + @JsonInclude(JsonInclude.Include.NON_NULL) + val route: String? = null, + @JsonInclude(JsonInclude.Include.NON_NULL) + val items: List? = null +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/menu/Menu.kt b/src/main/kotlin/kr/co/vividnext/sodalive/menu/Menu.kt new file mode 100644 index 0000000..d790707 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/menu/Menu.kt @@ -0,0 +1,32 @@ +package kr.co.vividnext.sodalive.menu + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.member.MemberRole +import javax.persistence.CascadeType +import javax.persistence.Column +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.OneToMany + +@Entity +data class Menu( + @Column(nullable = false) + val title: String, + @Column(nullable = false) + val route: String, + @Enumerated(value = EnumType.STRING) + val roles: MemberRole, + val orders: Int, + val isActive: Boolean +) : BaseEntity() { + @ManyToOne(fetch = FetchType.LAZY, cascade = [CascadeType.ALL]) + @JoinColumn(name = "parent_id", nullable = true) + var parent: Menu? = null + + @OneToMany(mappedBy = "parent", fetch = FetchType.EAGER) + var children: MutableList = mutableListOf() +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuController.kt new file mode 100644 index 0000000..5146119 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuController.kt @@ -0,0 +1,17 @@ +package kr.co.vividnext.sodalive.menu + +import kr.co.vividnext.sodalive.common.ApiResponse +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.security.core.userdetails.User +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/menu") +class MenuController(private val service: MenuService) { + @GetMapping + @PreAuthorize("hasAnyRole('AGENT', 'ADMIN')") + fun getMenus(@AuthenticationPrincipal user: User) = ApiResponse.ok(service.getMenus(user)) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuRepository.kt new file mode 100644 index 0000000..c5eb43f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuRepository.kt @@ -0,0 +1,47 @@ +package kr.co.vividnext.sodalive.menu + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.menu.QMenu.menu +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface MenuRepository : JpaRepository, MenuQueryRepository + +interface MenuQueryRepository { + fun getMenu(role: MemberRole): List +} + +class MenuQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : MenuQueryRepository { + override fun getMenu(role: MemberRole): List { + return queryFactory + .selectFrom(menu) + .where( + menu.isActive.isTrue + .and(menu.roles.eq(role)) + .and( + (menu.route.isNotNull.and(menu.parent.isNull)) + .or(menu.route.isNull.and(menu.children.isNotEmpty)) + ) + ) + .orderBy(menu.orders.asc()) + .fetch() + .asSequence() + .map { + GetMenuResponse( + title = it.title, + route = it.route, + items = if (it.children.isNotEmpty()) { + it.children + .asSequence() + .map { menu -> GetMenuResponse(menu.title, menu.route) } + .toList() + } else { + null + } + ) + } + .toList() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuService.kt new file mode 100644 index 0000000..bb7dc6e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuService.kt @@ -0,0 +1,18 @@ +package kr.co.vividnext.sodalive.menu + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.MemberRepository +import org.springframework.security.core.userdetails.User +import org.springframework.stereotype.Service + +@Service +class MenuService( + private val repository: MenuRepository, + private val memberRepository: MemberRepository +) { + fun getMenus(user: User): List { + val member = memberRepository.findByEmail(user.username) + ?: throw SodaException("로그인 정보를 확인해주세요.") + return repository.getMenu(member.role) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/message/GetMessageResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/message/GetMessageResponse.kt new file mode 100644 index 0000000..dfa3efb --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/message/GetMessageResponse.kt @@ -0,0 +1,35 @@ +package kr.co.vividnext.sodalive.message + +data class GetVoiceMessageResponse( + val totalCount: Int, + val items: List +) { + data class VoiceMessageItem( + val messageId: Long, + val senderId: Long, + val senderNickname: String, + val senderProfileImageUrl: String, + val recipientNickname: String, + val recipientProfileImageUrl: String, + val voiceMessageUrl: String, + val date: String, + val isKept: Boolean + ) +} + +data class GetTextMessageResponse( + val totalCount: Int, + val items: List +) { + data class TextMessageItem( + val messageId: Long, + val senderId: Long, + val senderNickname: String, + val senderProfileImageUrl: String, + val recipientNickname: String, + val recipientProfileImageUrl: String, + val textMessage: String, + val date: String, + val isKept: Boolean + ) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/message/Message.kt b/src/main/kotlin/kr/co/vividnext/sodalive/message/Message.kt new file mode 100644 index 0000000..35fed97 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/message/Message.kt @@ -0,0 +1,37 @@ +package kr.co.vividnext.sodalive.message + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.member.Member +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne + +@Entity +data class Message( + @Column(columnDefinition = "TEXT") + val textMessage: String? = null, + var voiceMessage: String? = null, + + @Enumerated(EnumType.STRING) + val messageType: MessageType = MessageType.TEXT, + + var isRecipientKeep: Boolean = false, + var isSenderDelete: Boolean = false, + var isRecipientDelete: Boolean = false +) : BaseEntity() { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "sender_id", nullable = false) + var sender: Member? = null + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "recipient_id", nullable = false) + var recipient: Member? = null +} + +enum class MessageType { + TEXT, VOICE +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/message/MessageController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/message/MessageController.kt new file mode 100644 index 0000000..1cb13b3 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/message/MessageController.kt @@ -0,0 +1,136 @@ +package kr.co.vividnext.sodalive.message + +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.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.RequestParam +import org.springframework.web.bind.annotation.RequestPart +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile + +@RestController +@RequestMapping("/message") +class MessageController(private val service: MessageService) { + @PostMapping("/send/text") + fun sendTextMessage( + @RequestBody request: SendTextMessageRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.sendTextMessage(request, member)) + } + + @GetMapping("/sent/text") + fun getSentTextMessages( + @RequestParam("timezone") timezone: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + pageable: Pageable + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.getSentTextMessages(member, pageable, timezone)) + } + + @GetMapping("/received/text") + fun getReceivedTextMessages( + @RequestParam("timezone") timezone: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + pageable: Pageable + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.getReceivedTextMessages(member, pageable, timezone)) + } + + @GetMapping("/keep/text") + fun getKeepTextMessages( + @RequestParam("timezone") timezone: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + pageable: Pageable + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.getKeepTextMessages(member, pageable, timezone)) + } + + @PutMapping("/keep/text/{id}") + fun keepTextMessage( + @PathVariable id: Long, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + ApiResponse.ok(service.keepTextMessage(id, member)) + } + + @PostMapping("/send/voice") + fun sendVoiceMessage( + @RequestPart("voiceMessageFile") voiceMessageFile: MultipartFile, + @RequestPart("request") requestString: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.sendVoiceMessage(voiceMessageFile, requestString, member)) + } + + @GetMapping("/sent/voice") + fun getSentVoiceMessages( + @RequestParam("timezone") timezone: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + pageable: Pageable + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + ApiResponse.ok(service.getSentVoiceMessages(member, pageable, timezone)) + } + + @GetMapping("/received/voice") + fun getReceivedVoiceMessages( + @RequestParam("timezone") timezone: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + pageable: Pageable + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.getReceivedVoiceMessages(member, pageable, timezone)) + } + + @GetMapping("/keep/voice") + fun getKeepVoiceMessages( + @RequestParam("timezone") timezone: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + pageable: Pageable + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.getKeepVoiceMessages(member, pageable, timezone)) + } + + @PutMapping("/keep/voice/{id}") + fun keepVoiceMessage( + @PathVariable id: Long, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + ApiResponse.ok(service.keepVoiceMessage(id, member)) + } + + @DeleteMapping("/{messageId}") + fun deleteMessage( + @PathVariable messageId: Long, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.deleteMessage(messageId, member)) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/message/MessageRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/message/MessageRepository.kt new file mode 100644 index 0000000..8e08125 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/message/MessageRepository.kt @@ -0,0 +1,146 @@ +package kr.co.vividnext.sodalive.message + +import com.querydsl.core.types.dsl.BooleanExpression +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.message.QMessage.message +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface MessageRepository : JpaRepository, MessageQueryRepository + +interface MessageQueryRepository { + fun getSentTextMessageCount(memberId: Long): Int + fun getSentTextMessageList(pageable: Pageable, memberId: Long): List + fun getReceivedTextMessageCount(memberId: Long): Int + fun getReceivedTextMessageList(pageable: Pageable, memberId: Long): List + fun getKeepTextMessageCount(memberId: Long): Int + fun getKeepTextMessageList(pageable: Pageable, memberId: Long): List + fun getSentVoiceMessageCount(memberId: Long): Int + fun getSentVoiceMessageList(pageable: Pageable, memberId: Long): List + fun getReceivedVoiceMessageCount(memberId: Long): Int + fun getReceivedVoiceMessageList(pageable: Pageable, memberId: Long): List + fun getKeepVoiceMessageCount(memberId: Long): Int + fun getKeepVoiceMessageList(pageable: Pageable, memberId: Long): List +} + +@Repository +class MessageQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : MessageQueryRepository { + override fun getSentTextMessageCount(memberId: Long): Int { + val where = message.messageType.eq(MessageType.TEXT) + .and(message.isSenderDelete.isFalse) + .and(message.sender.id.eq(memberId)) + + return totalCount(where) + } + + override fun getSentTextMessageList(pageable: Pageable, memberId: Long): List { + val where = message.messageType.eq(MessageType.TEXT) + .and(message.isSenderDelete.isFalse) + .and(message.sender.id.eq(memberId)) + + return messageList(pageable, where) + } + + override fun getReceivedTextMessageCount(memberId: Long): Int { + val where = message.messageType.eq(MessageType.TEXT) + .and(message.isRecipientDelete.isFalse) + .and(message.recipient.id.eq(memberId)) + + return totalCount(where) + } + + override fun getReceivedTextMessageList(pageable: Pageable, memberId: Long): List { + val where = message.messageType.eq(MessageType.TEXT) + .and(message.isRecipientDelete.isFalse) + .and(message.recipient.id.eq(memberId)) + + return messageList(pageable, where) + } + + override fun getKeepTextMessageCount(memberId: Long): Int { + val where = message.messageType.eq(MessageType.TEXT) + .and(message.isRecipientDelete.isFalse) + .and(message.isRecipientKeep.isTrue) + .and(message.recipient.id.eq(memberId)) + + return totalCount(where) + } + + override fun getKeepTextMessageList(pageable: Pageable, memberId: Long): List { + val where = message.messageType.eq(MessageType.TEXT) + .and(message.isRecipientDelete.isFalse) + .and(message.isRecipientKeep.isTrue) + .and(message.recipient.id.eq(memberId)) + + return messageList(pageable, where) + } + + override fun getSentVoiceMessageCount(memberId: Long): Int { + val where = message.messageType.eq(MessageType.VOICE) + .and(message.isSenderDelete.isFalse) + .and(message.sender.id.eq(memberId)) + + return totalCount(where) + } + + override fun getSentVoiceMessageList(pageable: Pageable, memberId: Long): List { + val where = message.messageType.eq(MessageType.VOICE) + .and(message.isSenderDelete.isFalse) + .and(message.sender.id.eq(memberId)) + + return messageList(pageable, where) + } + + override fun getReceivedVoiceMessageCount(memberId: Long): Int { + val where = message.messageType.eq(MessageType.VOICE) + .and(message.isRecipientDelete.isFalse) + .and(message.recipient.id.eq(memberId)) + + return totalCount(where) + } + + override fun getReceivedVoiceMessageList(pageable: Pageable, memberId: Long): List { + val where = message.messageType.eq(MessageType.VOICE) + .and(message.isRecipientDelete.isFalse) + .and(message.recipient.id.eq(memberId)) + + return messageList(pageable, where) + } + + override fun getKeepVoiceMessageCount(memberId: Long): Int { + val where = message.messageType.eq(MessageType.VOICE) + .and(message.isRecipientDelete.isFalse) + .and(message.isRecipientKeep.isTrue) + .and(message.recipient.id.eq(memberId)) + + return totalCount(where) + } + + override fun getKeepVoiceMessageList(pageable: Pageable, memberId: Long): List { + val where = message.messageType.eq(MessageType.VOICE) + .and(message.isRecipientDelete.isFalse) + .and(message.isRecipientKeep.isTrue) + .and(message.recipient.id.eq(memberId)) + + return messageList(pageable, where) + } + + private fun messageList(pageable: Pageable, where: BooleanExpression): List { + return queryFactory.selectFrom(message) + .offset(pageable.offset) + .limit(pageable.pageSize.toLong()) + .where(where) + .orderBy(message.id.desc()) + .fetch() + } + + private fun totalCount(where: BooleanExpression): Int { + return queryFactory.select(message.id) + .from(message) + .where(where) + .fetch() + .size + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/message/MessageService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/message/MessageService.kt new file mode 100644 index 0000000..07f22c8 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/message/MessageService.kt @@ -0,0 +1,262 @@ +package kr.co.vividnext.sodalive.message + +import com.amazonaws.services.s3.model.ObjectMetadata +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.fcm.FcmEvent +import kr.co.vividnext.sodalive.fcm.FcmEventType +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.block.BlockMemberRepository +import kr.co.vividnext.sodalive.utils.generateFileName +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.ApplicationEventPublisher +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.ZoneId +import java.time.format.DateTimeFormatter + +@Service +@Transactional(readOnly = true) +class MessageService( + private val repository: MessageRepository, + private val memberRepository: MemberRepository, + private val blockMemberRepository: BlockMemberRepository, + + private val applicationEventPublisher: ApplicationEventPublisher, + private val objectMapper: ObjectMapper, + private val s3Uploader: S3Uploader, + + @Value("\${cloud.aws.s3.bucket}") + private val bucket: String, + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) { + @Transactional + fun sendTextMessage(request: SendTextMessageRequest, member: Member) { + val recipient = memberRepository.findByIdOrNull(request.recipientId) + ?: throw SodaException("받는 사람이 없습니다.") + + if (!recipient.isActive) { + throw SodaException("탈퇴한 유저에게는 메시지를 보내실 수 없습니다.") + } + + val isBlocked = blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = request.recipientId) + if (isBlocked) throw SodaException("${recipient.nickname}님의 요청으로 메시지를 보낼 수 없습니다.") + + val sender = memberRepository.findByIdOrNull(member.id!!) + ?: throw SodaException("로그인 정보를 확인해주세요.") + + val message = Message( + textMessage = request.textMessage, + messageType = MessageType.TEXT + ) + + message.sender = sender + message.recipient = recipient + + repository.save(message) + + applicationEventPublisher.publishEvent( + FcmEvent( + type = FcmEventType.SEND_MESSAGE, + title = "메시지", + message = "${sender.nickname}님으로 부터 문자메시지가 도착했습니다.", + messageId = message.id + ) + ) + } + + fun getSentTextMessages(member: Member, pageable: Pageable, timezone: String): GetTextMessageResponse { + val totalCount = repository.getSentTextMessageCount(memberId = member.id!!) + val messageList = repository.getSentTextMessageList(pageable, memberId = member.id!!) + return getTextMessageResponse(totalCount, messageList, timezone) + } + + fun getReceivedTextMessages(member: Member, pageable: Pageable, timezone: String): GetTextMessageResponse { + val totalCount = repository.getReceivedTextMessageCount(memberId = member.id!!) + val messageList = repository.getReceivedTextMessageList(pageable, memberId = member.id!!) + return getTextMessageResponse(totalCount, messageList, timezone) + } + + fun getKeepTextMessages(member: Member, pageable: Pageable, timezone: String): GetTextMessageResponse { + val totalCount = repository.getKeepTextMessageCount(memberId = member.id!!) + val messageList = repository.getKeepTextMessageList(pageable, memberId = member.id!!) + return getTextMessageResponse(totalCount, messageList, timezone) + } + + @Transactional + fun keepTextMessage(messageId: Long, member: Member) { + keepMessage(messageId, member) + } + + @Transactional + fun sendVoiceMessage(voiceMessageFile: MultipartFile, requestString: String, member: Member) { + val request = objectMapper.readValue(requestString, SendVoiceMessageRequest::class.java) + + val recipient = memberRepository.findByIdOrNull(request.recipientId) + ?: throw SodaException("받는 사람이 없습니다.") + + if (!recipient.isActive) { + throw SodaException("탈퇴한 유저에게는 메시지를 보내실 수 없습니다.") + } + + val isBlocked = blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = request.recipientId) + if (isBlocked) throw SodaException("${recipient.nickname}님의 요청으로 메시지를 보낼 수 없습니다.") + + val sender = memberRepository.findByIdOrNull(member.id!!) + ?: throw SodaException("로그인 정보를 확인해주세요.") + + val message = Message(messageType = MessageType.VOICE) + message.sender = sender + message.recipient = recipient + + repository.save(message) + + val metadata = ObjectMetadata() + metadata.contentLength = voiceMessageFile.size + + val messagePath = s3Uploader.upload( + inputStream = voiceMessageFile.inputStream, + bucket = bucket, + filePath = "voice_message/${message.id}/${generateFileName(prefix = "${message.id}-message-")}", + metadata = metadata + ) + + message.voiceMessage = messagePath + + applicationEventPublisher.publishEvent( + FcmEvent( + type = FcmEventType.SEND_MESSAGE, + title = "메시지", + message = "${sender.nickname}님으로 부터 음성메시지가 도착했습니다.", + messageId = message.id + ) + ) + } + + fun getSentVoiceMessages(member: Member, pageable: Pageable, timezone: String): GetVoiceMessageResponse { + val totalCount = repository.getSentVoiceMessageCount(memberId = member.id!!) + val messageList = repository.getSentVoiceMessageList(pageable, memberId = member.id!!) + + return getVoiceMessageResponse(totalCount, messageList, timezone) + } + + fun getReceivedVoiceMessages(member: Member, pageable: Pageable, timezone: String): GetTextMessageResponse { + val totalCount = repository.getReceivedVoiceMessageCount(memberId = member.id!!) + val messageList = repository.getReceivedVoiceMessageList(pageable, memberId = member.id!!) + return getTextMessageResponse(totalCount, messageList, timezone) + } + + fun getKeepVoiceMessages(member: Member, pageable: Pageable, timezone: String): GetTextMessageResponse { + val totalCount = repository.getKeepVoiceMessageCount(memberId = member.id!!) + val messageList = repository.getKeepVoiceMessageList(pageable, memberId = member.id!!) + return getTextMessageResponse(totalCount, messageList, timezone) + } + + @Transactional + fun keepVoiceMessage(messageId: Long, member: Member) { + keepMessage(messageId, member) + } + + @Transactional + fun deleteMessage(messageId: Long, member: Member) { + val message = repository.findByIdOrNull(messageId) + ?: throw SodaException("해당하는 메시지가 없습니다.\n다시 확인해 주시기 바랍니다.") + + if (message.sender!!.id!! == member.id!!) { + message.isSenderDelete = true + } else if (message.recipient!!.id!! == member.id!!) { + message.isRecipientDelete = true + } + } + + private fun getTextMessageResponse( + totalCount: Int, + messageList: List, + timezone: String + ) = GetTextMessageResponse( + totalCount = totalCount, + items = messageList.asSequence() + .map { + val createdAt = it.createdAt!! + .atZone(ZoneId.of("UTC")) + .withZoneSameInstant(ZoneId.of(timezone)) + + GetTextMessageResponse.TextMessageItem( + messageId = it.id!!, + senderId = it.sender!!.id!!, + senderNickname = it.sender!!.nickname, + senderProfileImageUrl = if (it.sender!!.profileImage != null) { + "$cloudFrontHost/${it.sender!!.profileImage}" + } else { + "$cloudFrontHost/profile/default-profile.png" + }, + recipientNickname = it.recipient!!.nickname, + recipientProfileImageUrl = if (it.recipient!!.profileImage != null) { + "$cloudFrontHost/${it.recipient!!.profileImage}" + } else { + "$cloudFrontHost/profile/default-profile.png" + }, + textMessage = it.textMessage!!, + date = createdAt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")), + isKept = false + ) + } + .toList() + ) + + private fun getVoiceMessageResponse( + totalCount: Int, + messageList: List, + timezone: String + ) = GetVoiceMessageResponse( + totalCount = totalCount, + items = messageList.asSequence() + .map { + val createdAt = it.createdAt!! + .atZone(ZoneId.of("UTC")) + .withZoneSameInstant(ZoneId.of(timezone)) + + GetVoiceMessageResponse.VoiceMessageItem( + messageId = it.id!!, + senderId = it.sender!!.id!!, + senderNickname = it.sender!!.nickname, + senderProfileImageUrl = if (it.sender!!.profileImage != null) { + "$cloudFrontHost/${it.sender!!.profileImage}" + } else { + "$cloudFrontHost/profile/default-profile.png" + }, + recipientNickname = it.recipient!!.nickname, + recipientProfileImageUrl = if (it.recipient!!.profileImage != null) { + "$cloudFrontHost/${it.recipient!!.profileImage}" + } else { + "$cloudFrontHost/profile/default-profile.png" + }, + voiceMessageUrl = "$cloudFrontHost/${it.voiceMessage!!}", + date = createdAt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")), + isKept = it.isRecipientKeep + ) + } + .toList() + ) + + private fun keepMessage(messageId: Long, member: Member) { + val message = repository.findByIdOrNull(messageId) + ?: throw SodaException("잘못된 요청입니다.") + + if (message.recipient != member) { + throw SodaException("잘못된 요청입니다.") + } + + if (message.isRecipientKeep) { + throw SodaException("이미 보관된 메시지 입니다.") + } + + message.isRecipientKeep = true + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/message/SendMessageRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/message/SendMessageRequest.kt new file mode 100644 index 0000000..2afe5f5 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/message/SendMessageRequest.kt @@ -0,0 +1,5 @@ +package kr.co.vividnext.sodalive.message + +data class SendVoiceMessageRequest(val recipientId: Long, val container: String) + +data class SendTextMessageRequest(val recipientId: Long, val textMessage: String, val container: String) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/notice/CreateNoticeRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/notice/CreateNoticeRequest.kt new file mode 100644 index 0000000..5c48a40 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/notice/CreateNoticeRequest.kt @@ -0,0 +1,10 @@ +package kr.co.vividnext.sodalive.notice + +data class CreateNoticeRequest( + val title: String, + val content: String +) { + fun toEntity(): ServiceNotice { + return ServiceNotice(title, content) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/notice/GetNoticeResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/notice/GetNoticeResponse.kt new file mode 100644 index 0000000..aa35bbf --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/notice/GetNoticeResponse.kt @@ -0,0 +1,13 @@ +package kr.co.vividnext.sodalive.notice + +data class GetNoticeResponse( + val totalCount: Int, + val noticeList: List +) + +data class NoticeItem( + val id: Long, + val title: String, + val content: String, + val date: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNotice.kt b/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNotice.kt new file mode 100644 index 0000000..6800fff --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNotice.kt @@ -0,0 +1,15 @@ +package kr.co.vividnext.sodalive.notice + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Column +import javax.persistence.Entity + +@Entity +data class ServiceNotice( + @Column(nullable = false) + var title: String, + @Column(columnDefinition = "TEXT", nullable = false) + var content: String, + @Column(nullable = false) + var isActive: Boolean = true +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeController.kt new file mode 100644 index 0000000..e4f258f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeController.kt @@ -0,0 +1,38 @@ +package kr.co.vividnext.sodalive.notice + +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.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.RestController + +@RestController +@RequestMapping("/notice") +class ServiceNoticeController(private val service: ServiceNoticeService) { + @PostMapping + @PreAuthorize("hasRole('ADMIN')") + fun createNotice(@RequestBody request: CreateNoticeRequest) = ApiResponse.ok( + service.save(request), + "등록되었습니다." + ) + + @PutMapping + @PreAuthorize("hasRole('ADMIN')") + fun updateNotice(@RequestBody request: UpdateNoticeRequest) = ApiResponse.ok( + service.update(request), + "수정되었습니다." + ) + + @DeleteMapping("/{id}") + @PreAuthorize("hasRole('ADMIN')") + fun deleteCan(@PathVariable id: Long) = ApiResponse.ok(service.delete(id), "삭제되었습니다.") + + @GetMapping + fun getNoticeList(pageable: Pageable, timezone: String) = ApiResponse.ok(service.getNoticeList(pageable, timezone)) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeService.kt new file mode 100644 index 0000000..10d28c2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeService.kt @@ -0,0 +1,66 @@ +package kr.co.vividnext.sodalive.notice + +import kr.co.vividnext.sodalive.common.SodaException +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 +@Transactional(readOnly = true) +class ServiceNoticeService(private val repository: ServiceServiceNoticeRepository) { + @Transactional + fun save(request: CreateNoticeRequest): Long { + if (request.title.isBlank()) throw SodaException("제목을 입력하세요.") + if (request.content.isBlank()) throw SodaException("내용을 입력하세요.") + + val notice = request.toEntity() + return repository.save(notice).id!! + } + + @Transactional + fun update(request: UpdateNoticeRequest) { + if (request.id <= 0) throw SodaException("잘못된 요청입니다.") + if (request.title.isNullOrBlank() && request.content.isNullOrBlank()) { + throw SodaException("수정할 내용을 입력하세요.") + } + + val notice = repository.findByIdOrNull(request.id) + ?: throw SodaException("잘못된 요청입니다.") + + if (!request.title.isNullOrBlank()) notice.title = request.title + if (!request.content.isNullOrBlank()) notice.content = request.content + } + + @Transactional + fun delete(id: Long) { + if (id <= 0) throw SodaException("잘못된 요청입니다.") + val notice = repository.findByIdOrNull(id) + ?: throw SodaException("잘못된 요청입니다.") + + notice.isActive = false + } + + fun getNoticeList(pageable: Pageable, timezone: String): GetNoticeResponse { + val totalCount = repository.getNoticeTotalCount() + val noticeList = repository.getNoticeList(pageable) + .asSequence() + .map { + val createdAt = it.createdAt!! + .atZone(ZoneId.of("UTC")) + .withZoneSameInstant(ZoneId.of(timezone)) + + NoticeItem( + it.id!!, + it.title, + it.content, + createdAt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + ) + } + .toList() + + return GetNoticeResponse(totalCount, noticeList) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceServiceNoticeRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceServiceNoticeRepository.kt new file mode 100644 index 0000000..90d5a47 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceServiceNoticeRepository.kt @@ -0,0 +1,37 @@ +package kr.co.vividnext.sodalive.notice + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.notice.QServiceNotice.serviceNotice +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface ServiceServiceNoticeRepository : JpaRepository, ServiceNoticeQueryRepository + +interface ServiceNoticeQueryRepository { + fun getNoticeTotalCount(): Int + fun getNoticeList(pageable: Pageable): List +} + +@Repository +class ServiceNoticeQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : ServiceNoticeQueryRepository { + override fun getNoticeTotalCount(): Int { + return queryFactory + .select(serviceNotice.id) + .from(serviceNotice) + .where(serviceNotice.isActive.isTrue) + .fetch() + .size + } + + override fun getNoticeList(pageable: Pageable): List { + return queryFactory + .selectFrom(serviceNotice) + .where(serviceNotice.isActive.isTrue) + .offset(pageable.offset) + .limit(pageable.pageSize.toLong()) + .orderBy(serviceNotice.id.desc()) + .fetch() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/notice/UpdateNoticeRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/notice/UpdateNoticeRequest.kt new file mode 100644 index 0000000..c3da07d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/notice/UpdateNoticeRequest.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.notice + +data class UpdateNoticeRequest( + val id: Long, + val title: String? = null, + val content: String? = null +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/report/Report.kt b/src/main/kotlin/kr/co/vividnext/sodalive/report/Report.kt new file mode 100644 index 0000000..3133dcc --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/report/Report.kt @@ -0,0 +1,34 @@ +package kr.co.vividnext.sodalive.report + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers +import kr.co.vividnext.sodalive.member.Member +import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne + +@Entity +data class Report( + @Enumerated(value = EnumType.STRING) + val type: ReportType, + val reason: String +) : BaseEntity() { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + var member: Member? = null + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reported_member_id", nullable = true) + var reportedAccount: Member? = null + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "cheers_id", nullable = true) + var cheers: CreatorCheers? = null +} + +enum class ReportType { + PROFILE, USER, CHEERS, AUDIO_CONTENT +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/report/ReportController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/report/ReportController.kt new file mode 100644 index 0000000..10a2903 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/report/ReportController.kt @@ -0,0 +1,23 @@ +package kr.co.vividnext.sodalive.report + +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.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("/report") +class ReportController(private val service: ReportService) { + @PostMapping + fun report( + @RequestBody request: ReportRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + ApiResponse.ok(service.save(member, request), "신고가 접수되었습니다.") + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/report/ReportRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/report/ReportRepository.kt new file mode 100644 index 0000000..e6368c9 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/report/ReportRepository.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.report + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface ReportRepository : JpaRepository diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/report/ReportRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/report/ReportRequest.kt new file mode 100644 index 0000000..0c52797 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/report/ReportRequest.kt @@ -0,0 +1,9 @@ +package kr.co.vividnext.sodalive.report + +data class ReportRequest( + val type: ReportType, + val reason: String, + val reportedMemberId: Long? = null, + val cheersId: Long? = null, + val audioContentId: Long? = null +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/report/ReportService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/report/ReportService.kt new file mode 100644 index 0000000..2926dda --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/report/ReportService.kt @@ -0,0 +1,52 @@ +package kr.co.vividnext.sodalive.report + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository +import kr.co.vividnext.sodalive.member.Member +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 +@Transactional(readOnly = true) +class ReportService( + private val repository: ReportRepository, + private val memberRepository: MemberRepository, + private val cheersRepository: CreatorCheersRepository +) { + @Transactional + fun save(member: Member, request: ReportRequest) { + if (conditionAllIsNull(request, isNull = true) || conditionAllIsNull(request, isNull = false)) { + throw SodaException("신고가 접수되었습니다.") + } + + val reportedAccount = if (request.reportedMemberId != null) { + memberRepository.findByIdOrNull(request.reportedMemberId) + ?: throw SodaException("신고가 접수되었습니다.") + } else { + null + } + + val cheers = if (request.cheersId != null) { + cheersRepository.findByIdOrNull(request.cheersId) + ?: throw SodaException("신고가 접수되었습니다.") + } else { + null + } + + val report = Report(type = request.type, reason = request.reason) + report.member = member + report.reportedAccount = reportedAccount + report.cheers = cheers + repository.save(report) + } + + private fun conditionAllIsNull(request: ReportRequest, isNull: Boolean): Boolean { + return if (isNull) { + request.reportedMemberId == null && request.cheersId == null && request.audioContentId == null + } else { + request.reportedMemberId != null && request.cheersId != null && request.audioContentId != null + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/utils/Utils.kt b/src/main/kotlin/kr/co/vividnext/sodalive/utils/Utils.kt new file mode 100644 index 0000000..c4a54df --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/utils/Utils.kt @@ -0,0 +1,34 @@ +package kr.co.vividnext.sodalive.utils + +import java.nio.charset.StandardCharsets +import java.security.SecureRandom +import java.util.Base64 +import java.util.Random +import java.util.UUID + +fun generatePassword(length: Int): String { + val characterSet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&*()1234567890" + val random = SecureRandom() + val password = StringBuilder() + + for (i in 0 until length) { + val rIndex = random.nextInt(characterSet.length) + password.append(characterSet[rIndex]) + } + + return password.toString() +} + +fun generateFileName(prefix: String = ""): String { + val timestamp = System.currentTimeMillis() + val random = Random().nextInt(10000) + val uuid = UUID.randomUUID().toString() + + return if (prefix.isBlank()) { + Base64 + .getUrlEncoder() + .encodeToString("$uuid-$random-$timestamp".toByteArray(StandardCharsets.UTF_8)) + } else { + "$prefix-$uuid-$random-$timestamp" + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 8b13789..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..1941134 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,90 @@ +server: + shutdown: graceful + +logging: + level: + com: + amazonaws: + util: + EC2MetadataUtils: error + +bootpay: + applicationId: ${BOOTPAY_APPLICATION_ID} + privateKey: ${BOOTPAY_PRIVATE_KEY} + +apple: + iapVerifyUrl: https://buy.itunes.apple.com/verifyReceipt + iapVerifySandboxUrl: https://sandbox.itunes.apple.com/verifyReceipt + +agora: + appId: ${AGORA_APP_ID} + appCertificate: ${AGORA_APP_CERTIFICATE} + +firebase: + secretKeyPath: ${GOOGLE_APPLICATION_CREDENTIALS} + +cloud: + aws: + credentials: + accessKey: ${APP_AWS_ACCESS_KEY} + secretKey: ${APP_AWS_SECRET_KEY} + s3: + contentBucket: ${S3_CONTENT_BUCKET} + bucket: ${S3_BUCKET} + contentCloudFront: + host: ${CONTENT_CLOUD_FRONT_HOST} + privateKeyFilePath: ${CONTENT_CLOUD_FRONT_PRIVATE_KEY_FILE_PATH} + keyPairId: ${CONTENT_CLOUD_FRONT_KEY_PAIR_ID} + cloudFront: + host: ${CLOUD_FRONT_HOST} + region: + static: ap-northeast-2 + stack: + auto: false + +jwt: + header: Authorization + token-validity-in-seconds: ${JWT_TOKEN_VALIDITY_TIME} + secret: ${JWT_SECRET} + +spring: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT} + + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + + jpa: + hibernate: + ddl-auto: validate + database: mysql + properties: + hibernate: + dialect: org.hibernate.dialect.MySQL8Dialect + + servlet: + multipart: + max-file-size: 1024MB + max-request-size: 1024MB +--- +spring: + config: + activate: + on-profile: local + + devtools: + restart: + enabled: true + livereload: + enabled: true + + jpa: + properties: + hibernate: + show_sql: true + format_sql: true + diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/SodaliveApplicationTests.kt b/src/test/kotlin/kr/co/vividnext/sodalive/SodaliveApplicationTests.kt deleted file mode 100644 index d5738ce..0000000 --- a/src/test/kotlin/kr/co/vividnext/sodalive/SodaliveApplicationTests.kt +++ /dev/null @@ -1,13 +0,0 @@ -package kr.co.vividnext.sodalive - -import org.junit.jupiter.api.Test -import org.springframework.boot.test.context.SpringBootTest - -@SpringBootTest -class SodaliveApplicationTests { - - @Test - fun contextLoads() { - } - -} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 0000000..3fc8d6c --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,46 @@ +logging: + level: + com: + amazonaws: + util: + EC2MetadataUtils: error + +apple: + iapVerifyUrl: https://buy.itunes.apple.com/verifyReceipt + iapVerifySandboxUrl: https://sandbox.itunes.apple.com/verifyReceipt + +agora: + appId: ${AGORA_APP_ID} + appCertificate: ${AGORA_APP_CERTIFICATE} + +cloud: + aws: + credentials: + accessKey: ${APP_AWS_ACCESS_KEY} + secretKey: ${APP_AWS_SECRET_KEY} + s3: + bucket: ${S3_BUCKET} + cloudFront: + host: ${CLOUD_FRONT_HOST} + region: + static: ap-northeast-2 + stack: + auto: false + +jwt: + header: Authorization + token-validity-in-seconds: ${JWT_TOKEN_VALIDITY_TIME} + secret: ${JWT_SECRET} + +spring: + redis: + host: localhost + port: 6379 + + jpa: + hibernate: + ddl-auto: create-drop + properties: + hibernate: + show_sql: true + format_sql: true