Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,13 @@ dependencies {

kotlin {
compilerOptions {
freeCompilerArgs.addAll("-Xjsr305=strict")
// Without this, a Kotlin interface method with a body (e.g. MemberRepository's convenience
// overload of searchSelectableByNickname) compiles to a synthetic DefaultImpls dispatch instead of
// a real JVM `default` method. Spring Data's repository proxy only recognizes true JVM default
// methods via Method.isDefault() -- otherwise it treats the method as yet another abstract query
// method and tries (and fails) to derive a query from its name. This flag makes Kotlin emit real
// default methods so Spring Data dispatches to the actual Kotlin-written body instead.
freeCompilerArgs.addAll("-Xjsr305=strict", "-Xjvm-default=all")
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,12 @@ import team.cklob.mudda.domain.block.domain.entity.Block
interface BlockRepository : JpaRepository<Block, Long> {
fun existsByBlockerIdAndBlockedId(blockerId: Long, blockedId: Long): Boolean
fun findByBlockerId(blockerId: Long): List<Block>

// Bidirectional existence check: true if either member has blocked the other.
fun existsByBlockerIdAndBlockedIdOrBlockerIdAndBlockedId(
blockerId1: Long,
blockedId1: Long,
blockerId2: Long,
blockedId2: Long,
): Boolean
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package team.cklob.mudda.domain.friend.application.impl

import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import team.cklob.mudda.domain.friend.domain.repository.FriendRepository
import team.cklob.mudda.domain.friend.domain.type.FriendRequestStatus
import team.cklob.mudda.global.exception.BusinessException
import team.cklob.mudda.global.exception.ErrorCode

@Service
class DeleteFriendService(
private val friendRepository: FriendRepository,
) {
@Transactional
fun execute(memberId: Long, targetMemberId: Long) {
val relations = friendRepository.findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(memberId, targetMemberId, targetMemberId, memberId)
val friend = relations.firstOrNull { it.status == FriendRequestStatus.ACCEPTED } ?: throw BusinessException(ErrorCode.FRIEND_NOT_FOUND)
friendRepository.delete(friend)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package team.cklob.mudda.domain.friend.application.impl

import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import team.cklob.mudda.domain.friend.domain.entity.Friend
import team.cklob.mudda.domain.friend.domain.repository.FriendRepository
import team.cklob.mudda.domain.friend.presentation.response.FriendPageResponse
import team.cklob.mudda.domain.friend.presentation.response.FriendResponse
import team.cklob.mudda.domain.member.domain.entity.Member

@Service
class GetFriendListService(
private val friendRepository: FriendRepository,
) {
@Transactional(readOnly = true)
fun execute(memberId: Long, pageable: Pageable): FriendPageResponse<FriendResponse> {
// Blocked counterparts are already excluded by FriendRepository#findFriendships itself (NOT EXISTS
// in SQL), so the page's totalElements/totalPages/hasNext are accurate as-is -- no post-fetch
// filtering needed here.
val page = friendRepository.findFriendships(memberId, pageable)
// accepted_at is backed by ck_friend_accepted_at (see V4 migration): the DB itself guarantees an
// ACCEPTED row always has a non-null accepted_at, so this can never actually throw.
val content = page.content.map { FriendResponse.of(counterpart(it, memberId), requireNotNull(it.acceptedAt)) }

return FriendPageResponse.of(page, content)
}

private fun counterpart(friend: Friend, memberId: Long): Member = if (friend.requester.id == memberId) friend.receiver else friend.requester
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package team.cklob.mudda.domain.friend.application.impl

import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import team.cklob.mudda.domain.friend.domain.entity.Friend
import team.cklob.mudda.domain.friend.domain.repository.FriendRepository
import team.cklob.mudda.domain.friend.domain.type.FriendRequestStatus
import team.cklob.mudda.domain.friend.domain.type.FriendRequestType
import team.cklob.mudda.domain.friend.presentation.response.FriendPageResponse
import team.cklob.mudda.domain.friend.presentation.response.FriendRequestResponse

@Service
class GetFriendRequestListService(
private val friendRepository: FriendRepository,
) {
@Transactional(readOnly = true)
fun execute(memberId: Long, type: FriendRequestType, status: FriendRequestStatus, pageable: Pageable): FriendPageResponse<FriendRequestResponse> {
val page: Page<Friend> = when (type) {
FriendRequestType.RECEIVED -> friendRepository.findReceivedRequests(memberId, status, pageable)
FriendRequestType.SENT -> friendRepository.findSentRequests(memberId, status, pageable)
}

val content = page.content.map { friend ->
val counterpart = if (type == FriendRequestType.RECEIVED) friend.requester else friend.receiver
FriendRequestResponse.of(friend, type, counterpart)
}

return FriendPageResponse.of(page, content)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package team.cklob.mudda.domain.friend.application.impl

import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import team.cklob.mudda.domain.block.domain.repository.BlockRepository
import team.cklob.mudda.domain.friend.domain.repository.FriendRepository
import team.cklob.mudda.domain.friend.domain.type.FriendRequestAction
import team.cklob.mudda.domain.friend.domain.type.FriendRequestStatus
import team.cklob.mudda.domain.friend.presentation.request.RespondFriendRequestRequest
import team.cklob.mudda.global.exception.BusinessException
import team.cklob.mudda.global.exception.ErrorCode
import java.time.LocalDateTime

@Service
class RespondFriendRequestService(
private val friendRepository: FriendRepository,
private val blockRepository: BlockRepository,
) {
@Transactional
fun execute(memberId: Long, requestId: Long, request: RespondFriendRequestRequest) {
val friend = friendRepository.findById(requestId).orElseThrow { BusinessException(ErrorCode.FRIEND_REQUEST_NOT_FOUND) }
if (friend.receiver.id != memberId) throw BusinessException(ErrorCode.FRIEND_REQUEST_NOT_RECEIVER)
if (friend.status != FriendRequestStatus.PENDING) throw BusinessException(ErrorCode.FRIEND_REQUEST_ALREADY_PROCESSED)
Comment thread
hej090224 marked this conversation as resolved.

when (request.action) {
FriendRequestAction.ACCEPT -> {
val requesterId = requireNotNull(friend.requester.id)
// SendFriendRequestService only checks for a block at the moment the request is sent. A block
// created afterwards, while the request is still PENDING, must not be bypassed by simply
// accepting it -- re-verify here too. (Direction doesn't need to be distinguished the way
// SendFriendRequestService does: whichever side is blocked, the member calling this endpoint is
// the receiver, so a BLOCKED_MEMBER response never tells them something about the other party
// they couldn't already infer from being unable to accept.)
if (blockRepository.existsByBlockerIdAndBlockedIdOrBlockerIdAndBlockedId(memberId, requesterId, requesterId, memberId)) {
throw BusinessException(ErrorCode.BLOCKED_MEMBER)
}
friend.status = FriendRequestStatus.ACCEPTED
friend.acceptedAt = LocalDateTime.now()
}
FriendRequestAction.REJECT -> {
friend.status = FriendRequestStatus.REJECTED
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package team.cklob.mudda.domain.friend.application.impl

import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import team.cklob.mudda.domain.friend.domain.entity.Friend
import team.cklob.mudda.domain.friend.domain.repository.FriendRepository
import team.cklob.mudda.domain.friend.domain.type.FriendRequestStatus
import team.cklob.mudda.domain.friend.domain.type.FriendRequestType
import team.cklob.mudda.domain.friend.domain.type.FriendStatus
import team.cklob.mudda.domain.friend.presentation.response.FriendPageResponse
import team.cklob.mudda.domain.friend.presentation.response.FriendSearchResponse
import team.cklob.mudda.domain.member.domain.repository.MemberRepository
import team.cklob.mudda.global.exception.BusinessException
import team.cklob.mudda.global.exception.ErrorCode

@Service
class SearchFriendService(
private val memberRepository: MemberRepository,
private val friendRepository: FriendRepository,
) {
@Transactional(readOnly = true)
fun execute(memberId: Long, keyword: String, pageable: Pageable): FriendPageResponse<FriendSearchResponse> {
val trimmed = keyword.trim()
if (trimmed.isBlank()) throw BusinessException(ErrorCode.INVALID_SEARCH_KEYWORD)

val page = memberRepository.searchSelectableByNickname(memberId, trimmed, pageable)
val candidateIds = page.content.mapNotNull { it.id }
val relationsByOtherId = if (candidateIds.isEmpty()) emptyMap() else groupRelationsByOtherId(memberId, friendRepository.findAllBetween(memberId, candidateIds))

val content = page.content.map { candidate ->
val relation = relationsByOtherId[candidate.id]
val (status, direction) = resolveRelation(memberId, relation)
FriendSearchResponse.of(candidate, status, relation?.id, direction)
}

return FriendPageResponse.of(page, content)
}

// A requester/receiver pair can have relationship rows in both directions (see FriendRepository), so an
// ACCEPTED row always wins over a stray PENDING row for the same pair, mirroring GetMemberProfileService.
private fun groupRelationsByOtherId(memberId: Long, relations: List<Friend>): Map<Long, Friend> =
relations.groupBy { if (it.requester.id == memberId) it.receiver.id else it.requester.id }
.mapNotNull { (otherId, rels) ->
val chosen = rels.firstOrNull { it.status == FriendRequestStatus.ACCEPTED } ?: rels.firstOrNull { it.status == FriendRequestStatus.PENDING } ?: rels.first()
otherId?.let { it to chosen }
}.toMap()

private fun resolveRelation(memberId: Long, relation: Friend?): Pair<FriendStatus, FriendRequestType?> {
if (relation == null) return FriendStatus.NONE to null
val sentByMe = relation.requester.id == memberId
return when (relation.status) {
FriendRequestStatus.ACCEPTED -> FriendStatus.FRIEND to (if (sentByMe) FriendRequestType.SENT else FriendRequestType.RECEIVED)
FriendRequestStatus.PENDING -> (if (sentByMe) FriendStatus.REQUESTED else FriendStatus.RECEIVED) to (if (sentByMe) FriendRequestType.SENT else FriendRequestType.RECEIVED)
FriendRequestStatus.REJECTED -> FriendStatus.NONE to null
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package team.cklob.mudda.domain.friend.application.impl

import org.slf4j.LoggerFactory
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import team.cklob.mudda.domain.block.domain.repository.BlockRepository
import team.cklob.mudda.domain.friend.domain.entity.Friend
import team.cklob.mudda.domain.friend.domain.repository.FriendRepository
import team.cklob.mudda.domain.friend.domain.type.FriendRequestStatus
import team.cklob.mudda.domain.friend.presentation.request.SendFriendRequestRequest
import team.cklob.mudda.domain.friend.presentation.response.SendFriendRequestResponse
import team.cklob.mudda.domain.member.domain.repository.MemberRepository
import team.cklob.mudda.global.exception.AuthException
import team.cklob.mudda.global.exception.BusinessException
import team.cklob.mudda.global.exception.ErrorCode

@Service
class SendFriendRequestService(
private val friendRepository: FriendRepository,
private val memberRepository: MemberRepository,
private val blockRepository: BlockRepository,
) {
private val logger = LoggerFactory.getLogger(javaClass)

@Transactional
fun execute(memberId: Long, request: SendFriendRequestRequest): SendFriendRequestResponse {
// @field:NotNull on SendFriendRequestRequest.receiverId already rejects a null/missing value with a
// 400 before this service runs; requireNotNull here just documents that invariant for callers.
val receiverId = requireNotNull(request.receiverId)
if (memberId == receiverId) throw BusinessException(ErrorCode.CANNOT_REQUEST_SELF)

val requester = memberRepository.findById(memberId).orElseThrow { AuthException(ErrorCode.UNAUTHORIZED) }
if (requester.withdrawnAt != null) throw BusinessException(ErrorCode.WITHDRAWN_MEMBER)

val receiver = memberRepository.findById(receiverId).orElseThrow { BusinessException(ErrorCode.MEMBER_NOT_FOUND) }
if (receiver.withdrawnAt != null || receiver.nickname == null) throw BusinessException(ErrorCode.MEMBER_NOT_FOUND)

// Direction matters here: if I blocked them, telling them BLOCKED_MEMBER doesn't leak anything they
// don't already know. If they blocked me, BLOCKED_MEMBER would leak the fact that a block exists
// (unlike the search API, which silently excludes blocked members via a NOT EXISTS filter) -- so
// that direction is reported as MEMBER_NOT_FOUND instead, indistinguishable from a nonexistent id.
if (blockRepository.existsByBlockerIdAndBlockedId(memberId, receiverId)) {
throw BusinessException(ErrorCode.BLOCKED_MEMBER)
}
if (blockRepository.existsByBlockerIdAndBlockedId(receiverId, memberId)) {
throw BusinessException(ErrorCode.MEMBER_NOT_FOUND)
}

val existingRelations = friendRepository.findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(memberId, receiverId, receiverId, memberId)
existingRelations.forEach { relation ->
when {
relation.status == FriendRequestStatus.ACCEPTED -> throw BusinessException(ErrorCode.ALREADY_FRIENDS)
relation.status == FriendRequestStatus.PENDING && relation.requester.id == memberId -> throw BusinessException(ErrorCode.FRIEND_REQUEST_ALREADY_EXISTS)
relation.status == FriendRequestStatus.PENDING -> throw BusinessException(ErrorCode.REVERSE_FRIEND_REQUEST_EXISTS)
// A REJECTED row doesn't block a new request -- uq_friend_requester_receiver (see V4) is a
// partial index that excludes REJECTED rows, so a fresh row for the same direction can be
// inserted below even while the old REJECTED row is kept around as history.
}
}

val saved = try {
friendRepository.saveAndFlush(Friend(requester = requester, receiver = receiver, status = FriendRequestStatus.PENDING))
Comment thread
hej090224 marked this conversation as resolved.
} catch (e: DataIntegrityViolationException) {
// Safety net for a concurrent insert that raced past the checks above -- most likely the reverse-
// direction pending race guarded by uq_friend_pending_pair, but could in principle be any
// constraint on this table (e.g. a member row deleted mid-request). Logged with the original
// exception since folding every violation into one error code would otherwise hide the real cause.
logger.warn("friend request insert violated a constraint: requester={}, receiver={}", memberId, receiverId, e)
throw BusinessException(ErrorCode.REVERSE_FRIEND_REQUEST_EXISTS)
}
Comment thread
hej090224 marked this conversation as resolved.

return SendFriendRequestResponse.from(saved)
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
package team.cklob.mudda.domain.friend.domain.repository

import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import team.cklob.mudda.domain.friend.domain.entity.Friend
import team.cklob.mudda.domain.friend.domain.type.FriendRequestStatus
import java.util.Optional

interface FriendRepository : JpaRepository<Friend, Long> {
Expand All @@ -18,4 +23,66 @@ interface FriendRepository : JpaRepository<Friend, Long> {
requesterId2: Long,
receiverId2: Long,
): List<Friend>

// Fetches every relationship row (any status) between the viewer and a batch of other member ids in a
// single query, so a search-result page or similar batch lookup doesn't issue one query per candidate.
@Query(
"""
SELECT f FROM Friend f
WHERE (f.requester.id = :memberId AND f.receiver.id IN :otherIds)
OR (f.receiver.id = :memberId AND f.requester.id IN :otherIds)
""",
)
fun findAllBetween(@Param("memberId") memberId: Long, @Param("otherIds") otherIds: Collection<Long>): List<Friend>

// requester/receiver are eagerly fetched so the response mapping (counterpart nickname/profileImageUrl)
// doesn't trigger an N+1 lazy load per row. status is hardcoded to ACCEPTED (the only caller,
// GetFriendListService, always wants that; ORDER BY acceptedAt is meaningless for any other status
// since the column is only ever populated for ACCEPTED rows anyway). Blocked counterparts are excluded
// in SQL -- the same NOT EXISTS shape as MemberRepository#searchSelectableByNickname -- so pagination
// metadata (totalElements/totalPages/hasNext) stays accurate instead of drifting from a post-fetch
// filter. f.id DESC breaks ties for rows that share the same acceptedAt second, which is common when
// requests are accepted in a batch, so a stable page boundary doesn't skip or repeat a row.
@Query(
value = """
SELECT f FROM Friend f JOIN FETCH f.requester JOIN FETCH f.receiver
WHERE f.status = team.cklob.mudda.domain.friend.domain.type.FriendRequestStatus.ACCEPTED
AND (f.requester.id = :memberId OR f.receiver.id = :memberId)
AND NOT EXISTS (
SELECT 1 FROM Block b
WHERE (b.blocker.id = :memberId AND b.blocked.id = CASE WHEN f.requester.id = :memberId THEN f.receiver.id ELSE f.requester.id END)
OR (b.blocked.id = :memberId AND b.blocker.id = CASE WHEN f.requester.id = :memberId THEN f.receiver.id ELSE f.requester.id END)
)
ORDER BY f.acceptedAt DESC, f.id DESC
""",
countQuery = """
SELECT COUNT(f) FROM Friend f
WHERE f.status = team.cklob.mudda.domain.friend.domain.type.FriendRequestStatus.ACCEPTED
AND (f.requester.id = :memberId OR f.receiver.id = :memberId)
AND NOT EXISTS (
SELECT 1 FROM Block b
WHERE (b.blocker.id = :memberId AND b.blocked.id = CASE WHEN f.requester.id = :memberId THEN f.receiver.id ELSE f.requester.id END)
OR (b.blocked.id = :memberId AND b.blocker.id = CASE WHEN f.requester.id = :memberId THEN f.receiver.id ELSE f.requester.id END)
)
""",
)
fun findFriendships(@Param("memberId") memberId: Long, pageable: Pageable): Page<Friend>

@Query(
"""
SELECT f FROM Friend f JOIN FETCH f.requester JOIN FETCH f.receiver
WHERE f.receiver.id = :receiverId AND f.status = :status
ORDER BY f.createdAt DESC, f.id DESC
""",
)
fun findReceivedRequests(@Param("receiverId") receiverId: Long, @Param("status") status: FriendRequestStatus, pageable: Pageable): Page<Friend>

@Query(
"""
SELECT f FROM Friend f JOIN FETCH f.requester JOIN FETCH f.receiver
WHERE f.requester.id = :requesterId AND f.status = :status
ORDER BY f.createdAt DESC, f.id DESC
""",
)
fun findSentRequests(@Param("requesterId") requesterId: Long, @Param("status") status: FriendRequestStatus, pageable: Pageable): Page<Friend>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package team.cklob.mudda.domain.friend.domain.type

enum class FriendRequestAction {
ACCEPT,
REJECT,
}
Loading