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
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
package team.cklob.mudda.domain.auth.presentation.controller

import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.responses.ApiResponse as SwaggerApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses as SwaggerApiResponses
import io.swagger.v3.oas.annotations.security.SecurityRequirement
import io.swagger.v3.oas.annotations.tags.Tag
import jakarta.validation.Valid
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
Expand Down Expand Up @@ -28,6 +34,7 @@ import team.cklob.mudda.global.response.ApiResponse
import team.cklob.mudda.global.security.LoginUser
import team.cklob.mudda.global.util.BearerToken

@Tag(name = "Auth", description = "OAuth 로그인, 회원가입, 토큰 재발급, 로그아웃, 탈퇴 API")
@RestController
@RequestMapping("/api/v1/auth")
class AuthController(
Expand All @@ -37,28 +44,70 @@ class AuthController(
private val signoutAuthService: SignoutAuthService,
private val withdrawAuthService: WithdrawAuthService,
) {
@Operation(
summary = "OAuth 로그인",
description = "소셜 로그인 인가 코드로 로그인합니다. 인증이 필요 없는 엔드포인트입니다. " +
"최초 로그인이면 회원가입이 완료되지 않은 상태의 토큰이 발급되며, 이어서 `/signup` 을 호출해야 합니다.",
)
@SwaggerApiResponses(
SwaggerApiResponse(responseCode = "200", description = "로그인 성공"),
SwaggerApiResponse(responseCode = "400", description = "유효하지 않은 인가 코드(OAUTH_INVALID_CODE) 또는 지원하지 않는 제공자(OAUTH_PROVIDER_NOT_SUPPORTED)"),
SwaggerApiResponse(responseCode = "403", description = "탈퇴한 계정(WITHDRAWN_MEMBER)"),
)
@PostMapping("/oauth/{provider}")
fun oauthLogin(
@PathVariable provider: OAuthProvider,
@Parameter(description = "OAuth 제공자", example = "KAKAO") @PathVariable provider: OAuthProvider,
@Valid @RequestBody request: LoginAuthRequest,
): ResponseEntity<ApiResponse<LoginAuthResponse>> = ResponseEntity.ok(ApiResponse.success(loginAuthService.execute(provider, request)))

@Operation(summary = "회원가입", description = "OAuth 로그인 직후 닉네임 등 프로필 정보를 등록해 회원가입을 완료합니다.")
@SwaggerApiResponses(
SwaggerApiResponse(responseCode = "201", description = "회원가입 성공"),
SwaggerApiResponse(responseCode = "409", description = "이미 회원가입을 마친 회원(ALREADY_SIGNED_UP) 또는 닉네임 중복(NICKNAME_ALREADY_EXISTS)"),
)
@SecurityRequirement(name = "bearerAuth")
@PostMapping("/signup")
@ResponseStatus(HttpStatus.CREATED)
fun signup(@LoginUser memberId: Long, @Valid @RequestBody request: SignupAuthRequest) {
signupAuthService.execute(memberId, request)
}

@Operation(
summary = "토큰 재발급",
description = "리프레시 토큰으로 액세스 토큰을 재발급합니다. 인증이 필요 없는 엔드포인트이며, 리프레시 토큰은 `refreshToken` 헤더로 전달합니다.",
)
@SwaggerApiResponses(
SwaggerApiResponse(responseCode = "200", description = "재발급 성공"),
SwaggerApiResponse(responseCode = "401", description = "유효하지 않거나 만료된 리프레시 토큰(INVALID_REFRESH_TOKEN)"),
)
@PatchMapping("/reissue")
fun reissue(@RequestHeader("refreshToken") refreshTokenHeader: String): ResponseEntity<ApiResponse<ReissueAuthResponse>> =
fun reissue(
@Parameter(description = "리프레시 토큰. `Bearer ` 접두사를 포함합니다.", example = "Bearer ey...")
@RequestHeader("refreshToken") refreshTokenHeader: String,
): ResponseEntity<ApiResponse<ReissueAuthResponse>> =
ResponseEntity.ok(ApiResponse.success(reissueAuthService.execute(extractBearerToken(refreshTokenHeader))))

@Operation(summary = "로그아웃", description = "현재 액세스 토큰을 블랙리스트에 등록하고 리프레시 토큰을 폐기합니다.")
@SwaggerApiResponses(
SwaggerApiResponse(responseCode = "204", description = "로그아웃 성공"),
SwaggerApiResponse(responseCode = "401", description = "유효하지 않은 토큰(INVALID_TOKEN)"),
)
@SecurityRequirement(name = "bearerAuth")
@DeleteMapping("/signout")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun signout(@LoginUser memberId: Long, @RequestHeader("Authorization") authorization: String) {
signoutAuthService.execute(memberId, extractBearerToken(authorization))
}

@Operation(
summary = "회원 탈퇴",
description = "회원을 탈퇴 처리합니다. 같은 소셜 계정으로 다시 가입할 수 있도록 기존 행은 tombstone 처리됩니다.",
)
@SwaggerApiResponses(
SwaggerApiResponse(responseCode = "204", description = "탈퇴 성공"),
SwaggerApiResponse(responseCode = "401", description = "유효하지 않은 토큰(INVALID_TOKEN)"),
)
@SecurityRequirement(name = "bearerAuth")
@DeleteMapping("/withdraw")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun withdraw(@LoginUser memberId: Long, @RequestHeader("Authorization") authorization: String) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package team.cklob.mudda.domain.auth.presentation.request

import io.swagger.v3.oas.annotations.media.Schema
import jakarta.validation.constraints.NotBlank

@Schema(description = "OAuth 로그인 요청")
data class LoginAuthRequest(
@field:NotBlank
@Schema(description = "OAuth 제공자로부터 받은 인가 코드", example = "abc123")
val code: String,

@field:NotBlank
@Schema(description = "인가 코드를 발급받을 때 사용한 리다이렉트 URI", example = "https://mudda.app/oauth/callback")
val redirectUri: String,
)
Original file line number Diff line number Diff line change
@@ -1,23 +1,29 @@
package team.cklob.mudda.domain.auth.presentation.request

import io.swagger.v3.oas.annotations.media.Schema
import jakarta.validation.constraints.Max
import jakarta.validation.constraints.Min
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
import team.cklob.mudda.domain.member.domain.type.Gender

@Schema(description = "회원가입 요청")
data class SignupAuthRequest(
@field:NotBlank
@field:Size(max = 30)
@Schema(description = "실명", example = "박하민")
val name: String,

@field:NotBlank
@field:Size(max = 30)
@Schema(description = "닉네임. 전체에서 유일해야 합니다.", example = "hamin")
val nickname: String,

@Schema(description = "성별", example = "MALE")
val gender: Gender,

@field:Min(1900)
@field:Max(2100)
@Schema(description = "출생 연도", example = "2008")
val birthYear: Int,
)
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
package team.cklob.mudda.domain.auth.presentation.response

import io.swagger.v3.oas.annotations.media.Schema
@Schema(description = "OAuth 로그인 응답")
data class LoginAuthResponse(
@Schema(description = "액세스 토큰", example = "ey...")
val accessToken: String,
@Schema(description = "리프레시 토큰", example = "ey...")
val refreshToken: String,
@Schema(description = "true면 아직 회원가입이 완료되지 않은 상태이므로 이어서 /api/v1/auth/signup 을 호출해야 합니다.", example = "true")
val isNewMember: Boolean,
)
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package team.cklob.mudda.domain.auth.presentation.response

import io.swagger.v3.oas.annotations.media.Schema
@Schema(description = "토큰 재발급 응답")
data class ReissueAuthResponse(
@Schema(description = "새 액세스 토큰", example = "ey...")
val accessToken: String,
@Schema(description = "새 리프레시 토큰", example = "ey...")
val refreshToken: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package team.cklob.mudda.domain.block.application.impl

import org.springframework.data.domain.Pageable
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.block.presentation.request.CreateBlockRequest
import team.cklob.mudda.domain.block.presentation.response.BlockResponse
import team.cklob.mudda.domain.block.presentation.response.CreateBlockResponse
import team.cklob.mudda.domain.friend.presentation.response.FriendPageResponse
import team.cklob.mudda.domain.member.domain.repository.MemberRepository
import team.cklob.mudda.global.exception.BusinessException
import team.cklob.mudda.global.exception.ErrorCode

// Blocking is purely additive: it writes one tbl_block row and nothing else. Every read path already
// excludes blocked members in SQL (friend list, friend requests, member search, capsule access), so there
// is no friendship or pending-request cascade to keep in sync -- and unblocking restores the prior state
// for free.
@Service
class CreateBlockService(
private val blockRepository: BlockRepository,
private val memberRepository: MemberRepository,
) {
@Transactional
fun execute(memberId: Long, request: CreateBlockRequest): CreateBlockResponse {
val targetId = requireNotNull(request.memberId)
if (targetId == memberId) throw BusinessException(ErrorCode.CANNOT_BLOCK_SELF)

val target = memberRepository.findById(targetId).orElseThrow { BusinessException(ErrorCode.MEMBER_NOT_FOUND) }
if (target.withdrawnAt != null) throw BusinessException(ErrorCode.MEMBER_NOT_FOUND)
if (!memberRepository.existsById(memberId)) throw BusinessException(ErrorCode.MEMBER_NOT_FOUND)

// Blocking twice is the same end state as blocking once, so the existing row is returned rather than
// raising a conflict the client would have to special-case. The insert is atomic so two concurrent
// requests both get that answer instead of one of them hitting uq_block_blocker_blocked: the loser
// simply sees 0 rows affected and reads back the winner's row.
blockRepository.insertIfAbsent(memberId, targetId)
val block = blockRepository.findByBlockerIdAndBlockedId(memberId, targetId)
.orElseThrow { BusinessException(ErrorCode.MEMBER_NOT_FOUND) }
return CreateBlockResponse(requireNotNull(block.id), targetId, block.createdAt)
}
}

@Service
class DeleteBlockService(
private val blockRepository: BlockRepository,
) {
@Transactional
fun execute(memberId: Long, targetMemberId: Long) {
val block = blockRepository.findByBlockerIdAndBlockedId(memberId, targetMemberId)
.orElseThrow { BusinessException(ErrorCode.BLOCK_NOT_FOUND) }
blockRepository.delete(block)
}
}

@Service
class GetBlockListService(
private val blockRepository: BlockRepository,
) {
@Transactional(readOnly = true)
fun execute(memberId: Long, pageable: Pageable): FriendPageResponse<BlockResponse> {
val page = blockRepository.findByBlockerIdOrderByCreatedAtDesc(memberId, pageable)
return FriendPageResponse.of(page, page.content.map(BlockResponse::from))
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
package team.cklob.mudda.domain.block.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.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
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>
fun findByBlockerIdAndBlockedId(blockerId: Long, blockedId: Long): java.util.Optional<Block>

// JOIN FETCH so rendering each row's nickname and profile image doesn't trigger an N+1 lazy load.
@Query(
value = "SELECT b FROM Block b JOIN FETCH b.blocked WHERE b.blocker.id = :blockerId ORDER BY b.createdAt DESC, b.id DESC",
countQuery = "SELECT COUNT(b) FROM Block b WHERE b.blocker.id = :blockerId",
)
fun findByBlockerIdOrderByCreatedAtDesc(@Param("blockerId") blockerId: Long, pageable: Pageable): Page<Block>

// Bidirectional existence check: true if either member has blocked the other.
fun existsByBlockerIdAndBlockedIdOrBlockerIdAndBlockedId(
Expand All @@ -26,4 +37,20 @@ interface BlockRepository : JpaRepository<Block, Long> {
""",
)
fun findBlockedMemberIds(@Param("memberId") memberId: Long, @Param("otherIds") otherIds: Collection<Long>): Set<Long>

// Concurrent block requests would both pass a read-then-write check and collide on
// uq_block_blocker_blocked, turning the loser into a 500. Inserting atomically lets the loser simply
// observe 0 rows affected and read back the winner's row -- the same shape MediaRepository uses for
// its own unique-key race. Doing this via an exception instead would poison the transaction and make
// the follow-up read impossible.
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
value = """
INSERT INTO tbl_block (blocker_id, blocked_id, created_at)
VALUES (:blockerId, :blockedId, CURRENT_TIMESTAMP)
ON CONFLICT (blocker_id, blocked_id) DO NOTHING
""",
nativeQuery = true,
)
fun insertIfAbsent(@Param("blockerId") blockerId: Long, @Param("blockedId") blockedId: Long): Int
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package team.cklob.mudda.domain.block.presentation.controller

import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.responses.ApiResponse as SwaggerApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses as SwaggerApiResponses
import io.swagger.v3.oas.annotations.security.SecurityRequirement
import io.swagger.v3.oas.annotations.tags.Tag
import jakarta.validation.Valid
import org.springframework.data.domain.Pageable
import org.springframework.data.web.PageableDefault
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
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.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import team.cklob.mudda.domain.block.application.impl.CreateBlockService
import team.cklob.mudda.domain.block.application.impl.DeleteBlockService
import team.cklob.mudda.domain.block.application.impl.GetBlockListService
import team.cklob.mudda.domain.block.presentation.request.CreateBlockRequest
import team.cklob.mudda.domain.block.presentation.response.BlockResponse
import team.cklob.mudda.domain.block.presentation.response.CreateBlockResponse
import team.cklob.mudda.domain.friend.presentation.response.FriendPageResponse
import team.cklob.mudda.global.response.ApiResponse
import team.cklob.mudda.global.security.LoginUser

@Tag(name = "Block", description = "회원 차단 API")
@SecurityRequirement(name = "bearerAuth")
@RestController
@RequestMapping("/api/v1/blocks")
class BlockController(
private val createBlockService: CreateBlockService,
private val deleteBlockService: DeleteBlockService,
private val getBlockListService: GetBlockListService,
) {
@Operation(
summary = "회원 차단",
description = "대상 회원을 차단합니다. 차단 후에는 친구 목록·친구 요청·사용자 검색·캡슐 접근에서 서로가 보이지 않습니다. " +
"친구 관계나 대기 중인 요청을 삭제하지는 않으므로, 차단을 해제하면 이전 상태가 그대로 복원됩니다.",
)
@SwaggerApiResponses(
SwaggerApiResponse(responseCode = "201", description = "차단 성공. 이미 차단한 회원이면 기존 차단 정보를 그대로 반환합니다."),
SwaggerApiResponse(responseCode = "400", description = "자기 자신을 차단(CANNOT_BLOCK_SELF)"),
SwaggerApiResponse(responseCode = "404", description = "대상 회원 없음(MEMBER_NOT_FOUND)"),
)
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun createBlock(
@LoginUser memberId: Long,
@Valid @RequestBody request: CreateBlockRequest,
): ApiResponse<CreateBlockResponse> = ApiResponse.success(createBlockService.execute(memberId, request))

@Operation(summary = "차단 목록 조회", description = "로그인 사용자가 차단한 회원 목록을 최근 차단순으로 조회합니다.")
@GetMapping
fun getBlocks(
@LoginUser memberId: Long,
@PageableDefault(size = 20) pageable: Pageable,
): ResponseEntity<ApiResponse<FriendPageResponse<BlockResponse>>> =
ResponseEntity.ok(ApiResponse.success(getBlockListService.execute(memberId, pageable)))

@Operation(summary = "차단 해제", description = "차단을 해제합니다. 차단 이전의 친구 관계와 대기 중인 요청이 다시 보이게 됩니다.")
@SwaggerApiResponses(
SwaggerApiResponse(responseCode = "204", description = "해제 성공"),
SwaggerApiResponse(responseCode = "404", description = "차단 기록 없음(BLOCK_NOT_FOUND)"),
)
@DeleteMapping("/{memberId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun deleteBlock(
@LoginUser loginMemberId: Long,
@Parameter(description = "차단을 해제할 회원 ID") @PathVariable("memberId") targetMemberId: Long,
) {
deleteBlockService.execute(loginMemberId, targetMemberId)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package team.cklob.mudda.domain.block.presentation.request

import io.swagger.v3.oas.annotations.media.Schema
import jakarta.validation.constraints.NotNull

@Schema(description = "회원 차단 요청")
data class CreateBlockRequest(
@field:NotNull
@Schema(description = "차단할 회원 ID", example = "2")
val memberId: Long?,
)
Loading