Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 좋아요 기능 구현 #87

Merged
merged 14 commits into from
Jan 10, 2025
1 change: 1 addition & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ indent_style = space
trim_trailing_whitespace = true
insert_final_newline = true
max_line_length = 120
ktlint.no-wildcard-imports = false
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,4 @@ data_crawling/ProjectStack.java
data_crawling/project.stack.graphqls

.env
generated
3 changes: 3 additions & 0 deletions pofo-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,7 @@ dependencies {
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0")

implementation("com.google.cloud.sql:postgres-socket-factory:1.21.2")

implementation("org.springframework.retry:spring-retry")
implementation("org.springframework:spring-aspects")
}
2 changes: 2 additions & 0 deletions pofo-api/src/main/kotlin/org/pofo/api/ApiApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.autoconfigure.data.elasticsearch.ElasticsearchDataAutoConfiguration
import org.springframework.boot.autoconfigure.elasticsearch.ElasticsearchRestClientAutoConfiguration
import org.springframework.boot.runApplication
import org.springframework.retry.annotation.EnableRetry

@EnableRetry
@SpringBootApplication(
exclude = [ElasticsearchDataAutoConfiguration::class, ElasticsearchRestClientAutoConfiguration::class],
)
Expand Down
42 changes: 42 additions & 0 deletions pofo-api/src/main/kotlin/org/pofo/api/domain/like/LikeApiDocs.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package org.pofo.api.domain.like

import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.responses.ApiResponses
import io.swagger.v3.oas.annotations.tags.Tag
import org.pofo.api.common.response.ApiResponse
import org.pofo.api.domain.security.PrincipalDetails
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.PathVariable
import io.swagger.v3.oas.annotations.responses.ApiResponse as SwaggerResponse

@Tag(name = "[Like API]", description = "좋아요 관련 API")
interface LikeApiDocs {
@Operation(summary = "좋아요 등록", description = "특정 프로젝트에 좋아요를 등록합니다.")
@ApiResponses(
value = [
SwaggerResponse(
responseCode = "200",
description = "좋아요 등록 성공",
),
],
)
fun likeProject(
@Parameter(hidden = true) @AuthenticationPrincipal principalDetails: PrincipalDetails,
@PathVariable("projectId") projectId: Long,
): ApiResponse<Map<String, Int>>

@Operation(summary = "좋아요 해제", description = "특정 프로젝트에 등록된 좋아요를 해제합니다.")
@ApiResponses(
value = [
SwaggerResponse(
responseCode = "200",
description = "좋아요 해제 성공",
),
],
)
fun unlikeProject(
@Parameter(hidden = true) @AuthenticationPrincipal principalDetails: PrincipalDetails,
@PathVariable("projectId") projectId: Long,
): ApiResponse<Map<String, Int>>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package org.pofo.api.domain.like

import org.pofo.api.common.response.ApiResponse
import org.pofo.api.domain.security.PrincipalDetails
import org.pofo.common.response.Version
import org.springframework.security.core.annotation.AuthenticationPrincipal
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.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping(Version.V1 + "/likes")
sukjuhong marked this conversation as resolved.
Show resolved Hide resolved
class LikeController(
private val likeService: LikeService,
) : LikeApiDocs {
@PostMapping("/{projectId}")
override fun likeProject(
@AuthenticationPrincipal principalDetails: PrincipalDetails,
@PathVariable projectId: Long,
): ApiResponse<Map<String, Int>> {
val currentLikes = likeService.likeProject(principalDetails.jwtTokenData.userId, projectId)
return ApiResponse.success(mapOf("likes" to currentLikes))
}

@DeleteMapping("/{projectId}")
override fun unlikeProject(
@AuthenticationPrincipal principalDetails: PrincipalDetails,
@PathVariable projectId: Long,
): ApiResponse<Map<String, Int>> {
val currentLikes = likeService.unlikeProject(principalDetails.jwtTokenData.userId, projectId)
return ApiResponse.success(mapOf("likes" to currentLikes))
}
}
105 changes: 105 additions & 0 deletions pofo-api/src/main/kotlin/org/pofo/api/domain/like/LikeService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package org.pofo.api.domain.like

import org.hibernate.StaleObjectStateException
import org.pofo.common.exception.CustomException
import org.pofo.common.exception.ErrorCode
import org.pofo.domain.rds.domain.like.Like
import org.pofo.domain.rds.domain.like.LikeRepository
import org.pofo.domain.rds.domain.project.repository.ProjectRepository
import org.pofo.domain.rds.domain.user.UserRepository
import org.springframework.dao.OptimisticLockingFailureException
import org.springframework.retry.annotation.Backoff
import org.springframework.retry.annotation.Retryable
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
@Transactional(readOnly = true)
class LikeService(
private val likeRepository: LikeRepository,
private val projectRepository: ProjectRepository,
private val userRepository: UserRepository,
) {
@Retryable(
value = [OptimisticLockingFailureException::class, StaleObjectStateException::class],
maxAttempts = 10,
backoff = Backoff(delay = 50, multiplier = 2.0),
)
@Transactional
fun likeProject(
userId: Long,
projectId: Long,
): Int {
val user =
userRepository.findById(userId)
?: throw CustomException(ErrorCode.USER_NOT_FOUND)

val project =
projectRepository.findById(projectId)
?: throw CustomException(ErrorCode.PROJECT_NOT_FOUND)

if (likeRepository.existsByUserAndProject(user, project)) {
throw CustomException(ErrorCode.ALREADY_LIKED_PROJECT)
}

val like =
Like
.builder()
.project(project)
.user(user)
.build()
project.increaseLikes()
projectRepository.save(project)
likeRepository.save(like)
return project.likes
}

@Retryable(
value = [OptimisticLockingFailureException::class, StaleObjectStateException::class],
maxAttempts = 10,
backoff = Backoff(delay = 50, multiplier = 2.0),
)
@Transactional
fun unlikeProject(
userId: Long,
projectId: Long,
): Int {
val user =
userRepository.findById(userId)
?: throw CustomException(ErrorCode.USER_NOT_FOUND)

val project =
projectRepository.findById(projectId)
?: throw CustomException(ErrorCode.PROJECT_NOT_FOUND)

val like =
likeRepository.findByUserAndProject(user, project)
?: throw CustomException(ErrorCode.LIKE_NOT_FOUND)

project.decreaseLikes()
projectRepository.save(project)
likeRepository.delete(like)
return project.likes
}

@Deprecated(message = "이유를 모르겠으나 제대로 작동안하는 재시도 로직. 참고용으로 남김")
private fun <T> retryOptimisticLock(action: () -> T): T {
var attempts = 0
val maxRetries = 10
var waitTime = 50L
while (true) {
try {
return action()
} catch (ex: OptimisticLockingFailureException) {
if (++attempts > maxRetries) {
throw CustomException(ErrorCode.LIKE_FAILED)
}
} catch (ex: StaleObjectStateException) {
if (++attempts > maxRetries) {
throw CustomException(ErrorCode.LIKE_FAILED)
}
}
Thread.sleep(waitTime)
}
}
}
Loading
Loading