From 34ad95a2621608a4dee8adb66c332f2a291bcbbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=95=98=EB=AF=BC?= Date: Sun, 30 Aug 2026 12:37:10 +0900 Subject: [PATCH 1/6] feat: #30 :: add block and report apis --- .../block/application/impl/BlockServices.kt | 67 ++++++++++++++++ .../domain/repository/BlockRepository.kt | 10 +++ .../controller/BlockController.kt | 79 +++++++++++++++++++ .../presentation/request/BlockRequests.kt | 11 +++ .../presentation/response/BlockResponses.kt | 45 +++++++++++ .../domain/repository/FriendRepository.kt | 35 +++++++- .../application/impl/CreateReportService.kt | 69 ++++++++++++++++ .../domain/report/domain/entity/Report.kt | 10 ++- .../domain/repository/ReportRepository.kt | 3 +- .../domain/report/domain/type/ReportTypes.kt | 17 ++++ .../controller/ReportController.kt | 44 +++++++++++ .../presentation/request/ReportRequests.kt | 26 ++++++ .../presentation/response/ReportResponses.kt | 35 ++++++++ .../cklob/mudda/global/exception/ErrorCode.kt | 5 ++ 14 files changed, 451 insertions(+), 5 deletions(-) create mode 100644 src/main/kotlin/team/cklob/mudda/domain/block/application/impl/BlockServices.kt create mode 100644 src/main/kotlin/team/cklob/mudda/domain/block/presentation/controller/BlockController.kt create mode 100644 src/main/kotlin/team/cklob/mudda/domain/block/presentation/request/BlockRequests.kt create mode 100644 src/main/kotlin/team/cklob/mudda/domain/block/presentation/response/BlockResponses.kt create mode 100644 src/main/kotlin/team/cklob/mudda/domain/report/application/impl/CreateReportService.kt create mode 100644 src/main/kotlin/team/cklob/mudda/domain/report/domain/type/ReportTypes.kt create mode 100644 src/main/kotlin/team/cklob/mudda/domain/report/presentation/controller/ReportController.kt create mode 100644 src/main/kotlin/team/cklob/mudda/domain/report/presentation/request/ReportRequests.kt create mode 100644 src/main/kotlin/team/cklob/mudda/domain/report/presentation/response/ReportResponses.kt diff --git a/src/main/kotlin/team/cklob/mudda/domain/block/application/impl/BlockServices.kt b/src/main/kotlin/team/cklob/mudda/domain/block/application/impl/BlockServices.kt new file mode 100644 index 0000000..acc7624 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/block/application/impl/BlockServices.kt @@ -0,0 +1,67 @@ +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.entity.Block +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) + + // 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. + val existing = blockRepository.findByBlockerIdAndBlockedId(memberId, targetId).orElse(null) + if (existing != null) { + return CreateBlockResponse(requireNotNull(existing.id), targetId, existing.createdAt) + } + + val blocker = memberRepository.findById(memberId).orElseThrow { BusinessException(ErrorCode.MEMBER_NOT_FOUND) } + val saved = blockRepository.save(Block(blocker = blocker, blocked = target)) + return CreateBlockResponse(requireNotNull(saved.id), targetId, saved.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 { + val page = blockRepository.findByBlockerIdOrderByCreatedAtDesc(memberId, pageable) + return FriendPageResponse.of(page, page.content.map(BlockResponse::from)) + } +} diff --git a/src/main/kotlin/team/cklob/mudda/domain/block/domain/repository/BlockRepository.kt b/src/main/kotlin/team/cklob/mudda/domain/block/domain/repository/BlockRepository.kt index 2a54297..5fa59db 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/block/domain/repository/BlockRepository.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/block/domain/repository/BlockRepository.kt @@ -1,5 +1,7 @@ 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.Query import org.springframework.data.repository.query.Param @@ -8,6 +10,14 @@ import team.cklob.mudda.domain.block.domain.entity.Block interface BlockRepository : JpaRepository { fun existsByBlockerIdAndBlockedId(blockerId: Long, blockedId: Long): Boolean fun findByBlockerId(blockerId: Long): List + fun findByBlockerIdAndBlockedId(blockerId: Long, blockedId: Long): java.util.Optional + + // 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 // Bidirectional existence check: true if either member has blocked the other. fun existsByBlockerIdAndBlockedIdOrBlockerIdAndBlockedId( diff --git a/src/main/kotlin/team/cklob/mudda/domain/block/presentation/controller/BlockController.kt b/src/main/kotlin/team/cklob/mudda/domain/block/presentation/controller/BlockController.kt new file mode 100644 index 0000000..9115891 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/block/presentation/controller/BlockController.kt @@ -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 = ApiResponse.success(createBlockService.execute(memberId, request)) + + @Operation(summary = "차단 목록 조회", description = "로그인 사용자가 차단한 회원 목록을 최근 차단순으로 조회합니다.") + @GetMapping + fun getBlocks( + @LoginUser memberId: Long, + @PageableDefault(size = 20) pageable: Pageable, + ): ResponseEntity>> = + 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) + } +} diff --git a/src/main/kotlin/team/cklob/mudda/domain/block/presentation/request/BlockRequests.kt b/src/main/kotlin/team/cklob/mudda/domain/block/presentation/request/BlockRequests.kt new file mode 100644 index 0000000..8155e17 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/block/presentation/request/BlockRequests.kt @@ -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?, +) diff --git a/src/main/kotlin/team/cklob/mudda/domain/block/presentation/response/BlockResponses.kt b/src/main/kotlin/team/cklob/mudda/domain/block/presentation/response/BlockResponses.kt new file mode 100644 index 0000000..23c1905 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/block/presentation/response/BlockResponses.kt @@ -0,0 +1,45 @@ +package team.cklob.mudda.domain.block.presentation.response + +import io.swagger.v3.oas.annotations.media.Schema +import team.cklob.mudda.domain.block.domain.entity.Block +import java.time.LocalDateTime + +@Schema(description = "차단한 회원") +data class BlockResponse( + @Schema(description = "차단 ID", example = "1") + val blockId: Long, + + @Schema(description = "차단된 회원 ID", example = "2") + val memberId: Long, + + @Schema(description = "차단된 회원 닉네임", example = "nick", nullable = true) + val nickname: String?, + + @Schema(description = "프로필 이미지 URL", nullable = true) + val profileImageUrl: String?, + + @Schema(description = "차단 시각") + val createdAt: LocalDateTime, +) { + companion object { + fun from(block: Block) = BlockResponse( + blockId = requireNotNull(block.id), + memberId = requireNotNull(block.blocked.id), + nickname = block.blocked.nickname, + profileImageUrl = block.blocked.profileImageUrl, + createdAt = block.createdAt, + ) + } +} + +@Schema(description = "회원 차단 응답") +data class CreateBlockResponse( + @Schema(description = "차단 ID", example = "1") + val blockId: Long, + + @Schema(description = "차단된 회원 ID", example = "2") + val memberId: Long, + + @Schema(description = "차단 시각") + val createdAt: LocalDateTime, +) diff --git a/src/main/kotlin/team/cklob/mudda/domain/friend/domain/repository/FriendRepository.kt b/src/main/kotlin/team/cklob/mudda/domain/friend/domain/repository/FriendRepository.kt index cf705f7..140073e 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/friend/domain/repository/FriendRepository.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/friend/domain/repository/FriendRepository.kt @@ -89,21 +89,52 @@ interface FriendRepository : JpaRepository { ) fun findFriendships(@Param("memberId") memberId: Long, pageable: Pageable): Page + // Blocked counterparts are excluded in SQL, the same NOT EXISTS shape findFriendships uses. Without it + // a blocked member's pending request stays visible even though RespondFriendRequestService re-checks + // the block and refuses the accept -- the receiver would see a request they can never act on. @Query( - """ + value = """ SELECT f FROM Friend f JOIN FETCH f.requester JOIN FETCH f.receiver WHERE f.receiver.id = :receiverId AND f.status = :status + AND NOT EXISTS ( + SELECT 1 FROM Block b + WHERE (b.blocker.id = :receiverId AND b.blocked.id = f.requester.id) + OR (b.blocked.id = :receiverId AND b.blocker.id = f.requester.id) + ) ORDER BY f.createdAt DESC, f.id DESC """, + countQuery = """ + SELECT COUNT(f) FROM Friend f + WHERE f.receiver.id = :receiverId AND f.status = :status + AND NOT EXISTS ( + SELECT 1 FROM Block b + WHERE (b.blocker.id = :receiverId AND b.blocked.id = f.requester.id) + OR (b.blocked.id = :receiverId AND b.blocker.id = f.requester.id) + ) + """, ) fun findReceivedRequests(@Param("receiverId") receiverId: Long, @Param("status") status: FriendRequestStatus, pageable: Pageable): Page @Query( - """ + value = """ SELECT f FROM Friend f JOIN FETCH f.requester JOIN FETCH f.receiver WHERE f.requester.id = :requesterId AND f.status = :status + AND NOT EXISTS ( + SELECT 1 FROM Block b + WHERE (b.blocker.id = :requesterId AND b.blocked.id = f.receiver.id) + OR (b.blocked.id = :requesterId AND b.blocker.id = f.receiver.id) + ) ORDER BY f.createdAt DESC, f.id DESC """, + countQuery = """ + SELECT COUNT(f) FROM Friend f + WHERE f.requester.id = :requesterId AND f.status = :status + AND NOT EXISTS ( + SELECT 1 FROM Block b + WHERE (b.blocker.id = :requesterId AND b.blocked.id = f.receiver.id) + OR (b.blocked.id = :requesterId AND b.blocker.id = f.receiver.id) + ) + """, ) fun findSentRequests(@Param("requesterId") requesterId: Long, @Param("status") status: FriendRequestStatus, pageable: Pageable): Page } diff --git a/src/main/kotlin/team/cklob/mudda/domain/report/application/impl/CreateReportService.kt b/src/main/kotlin/team/cklob/mudda/domain/report/application/impl/CreateReportService.kt new file mode 100644 index 0000000..2c3ccf4 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/report/application/impl/CreateReportService.kt @@ -0,0 +1,69 @@ +package team.cklob.mudda.domain.report.application.impl + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import team.cklob.mudda.domain.member.domain.repository.MemberRepository +import team.cklob.mudda.domain.report.domain.entity.Report +import team.cklob.mudda.domain.report.domain.repository.ReportRepository +import team.cklob.mudda.domain.report.domain.type.ReportReason +import team.cklob.mudda.domain.report.domain.type.ReportTargetType +import team.cklob.mudda.domain.report.presentation.request.CreateReportRequest +import team.cklob.mudda.domain.report.presentation.response.CreateReportResponse +import team.cklob.mudda.domain.timecapsule.domain.repository.GuestbookRepository +import team.cklob.mudda.domain.timecapsule.domain.repository.TimeCapsuleRepository +import team.cklob.mudda.global.exception.BusinessException +import team.cklob.mudda.global.exception.ErrorCode + +@Service +class CreateReportService( + private val reportRepository: ReportRepository, + private val memberRepository: MemberRepository, + private val capsuleRepository: TimeCapsuleRepository, + private val guestbookRepository: GuestbookRepository, +) { + @Transactional + fun execute(memberId: Long, request: CreateReportRequest): CreateReportResponse { + val targetType = requireNotNull(request.targetType) + val targetId = requireNotNull(request.targetId) + val reason = requireNotNull(request.reason) + + // ETC carries no meaning on its own -- without a description the report is unactionable for whoever + // reviews it, so it is rejected at the boundary rather than stored as noise. + if (reason == ReportReason.ETC && request.description.isNullOrBlank()) { + throw BusinessException(ErrorCode.INVALID_INPUT) + } + if (targetType == ReportTargetType.MEMBER && targetId == memberId) { + throw BusinessException(ErrorCode.CANNOT_REPORT_SELF) + } + requireTargetExists(targetType, targetId, memberId) + + // uq_report_reporter_target enforces this too; checking first turns a constraint violation into a + // meaningful 409 instead of a 500. + if (reportRepository.existsByReporterIdAndTargetTypeAndTargetId(memberId, targetType, targetId)) { + throw BusinessException(ErrorCode.ALREADY_REPORTED) + } + + val reporter = memberRepository.findById(memberId).orElseThrow { BusinessException(ErrorCode.MEMBER_NOT_FOUND) } + val saved = reportRepository.save( + Report( + reporter = reporter, + targetType = targetType, + targetId = targetId, + reason = reason, + description = request.description?.trim(), + ), + ) + return CreateReportResponse.from(saved) + } + + // A report against something that does not exist is worthless to a reviewer, and accepting one lets a + // caller probe which ids exist. Both are avoided by verifying the target up front. + private fun requireTargetExists(targetType: ReportTargetType, targetId: Long, memberId: Long) { + val exists = when (targetType) { + ReportTargetType.MEMBER -> memberRepository.findById(targetId).filter { it.withdrawnAt == null }.isPresent + ReportTargetType.CAPSULE -> capsuleRepository.findById(targetId).filter { !it.isDeleted }.isPresent + ReportTargetType.GUESTBOOK -> guestbookRepository.findById(targetId).filter { !it.isDeleted }.isPresent + } + if (!exists) throw BusinessException(ErrorCode.REPORT_TARGET_NOT_FOUND) + } +} diff --git a/src/main/kotlin/team/cklob/mudda/domain/report/domain/entity/Report.kt b/src/main/kotlin/team/cklob/mudda/domain/report/domain/entity/Report.kt index 7e727ff..f757abc 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/report/domain/entity/Report.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/report/domain/entity/Report.kt @@ -10,7 +10,11 @@ import jakarta.persistence.JoinColumn import jakarta.persistence.ManyToOne import jakarta.persistence.Table import jakarta.persistence.UniqueConstraint +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated import team.cklob.mudda.domain.member.domain.entity.Member +import team.cklob.mudda.domain.report.domain.type.ReportReason +import team.cklob.mudda.domain.report.domain.type.ReportTargetType import team.cklob.mudda.global.common.entity.BaseCreatedAtEntity @Entity @@ -28,14 +32,16 @@ class Report( @JoinColumn(name = "reporter_id", nullable = false) val reporter: Member, + @Enumerated(EnumType.STRING) @Column(name = "target_type", nullable = false, length = 30) - val targetType: String, + val targetType: ReportTargetType, @Column(name = "target_id", nullable = false) val targetId: Long, + @Enumerated(EnumType.STRING) @Column(nullable = false, length = 30) - val reason: String, + val reason: ReportReason, @Column(length = 500) val description: String? = null, diff --git a/src/main/kotlin/team/cklob/mudda/domain/report/domain/repository/ReportRepository.kt b/src/main/kotlin/team/cklob/mudda/domain/report/domain/repository/ReportRepository.kt index 44eb2db..6f95fae 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/report/domain/repository/ReportRepository.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/report/domain/repository/ReportRepository.kt @@ -2,11 +2,12 @@ package team.cklob.mudda.domain.report.domain.repository import org.springframework.data.jpa.repository.JpaRepository import team.cklob.mudda.domain.report.domain.entity.Report +import team.cklob.mudda.domain.report.domain.type.ReportTargetType interface ReportRepository : JpaRepository { fun existsByReporterIdAndTargetTypeAndTargetId( reporterId: Long, - targetType: String, + targetType: ReportTargetType, targetId: Long, ): Boolean } diff --git a/src/main/kotlin/team/cklob/mudda/domain/report/domain/type/ReportTypes.kt b/src/main/kotlin/team/cklob/mudda/domain/report/domain/type/ReportTypes.kt new file mode 100644 index 0000000..b55dc39 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/report/domain/type/ReportTypes.kt @@ -0,0 +1,17 @@ +package team.cklob.mudda.domain.report.domain.type + +// Persisted as VARCHAR(30) via @Enumerated(STRING); the columns already exist, so widening the model from +// raw String to enums needs no migration. +enum class ReportTargetType { + MEMBER, + CAPSULE, + GUESTBOOK, +} + +enum class ReportReason { + SPAM, + ABUSE, + SEXUAL, + FALSE_INFO, + ETC, +} diff --git a/src/main/kotlin/team/cklob/mudda/domain/report/presentation/controller/ReportController.kt b/src/main/kotlin/team/cklob/mudda/domain/report/presentation/controller/ReportController.kt new file mode 100644 index 0000000..5568170 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/report/presentation/controller/ReportController.kt @@ -0,0 +1,44 @@ +package team.cklob.mudda.domain.report.presentation.controller + +import io.swagger.v3.oas.annotations.Operation +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.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.report.application.impl.CreateReportService +import team.cklob.mudda.domain.report.presentation.request.CreateReportRequest +import team.cklob.mudda.domain.report.presentation.response.CreateReportResponse +import team.cklob.mudda.global.response.ApiResponse +import team.cklob.mudda.global.security.LoginUser + +@Tag(name = "Report", description = "회원·캡슐·방명록 신고 API") +@SecurityRequirement(name = "bearerAuth") +@RestController +@RequestMapping("/api/v1/reports") +class ReportController( + private val createReportService: CreateReportService, +) { + @Operation( + summary = "신고 접수", + description = "회원, 캡슐, 방명록을 신고합니다. 같은 대상을 중복 신고할 수 없으며, 사유가 ETC이면 description이 필수입니다.", + ) + @SwaggerApiResponses( + SwaggerApiResponse(responseCode = "201", description = "접수 성공"), + SwaggerApiResponse(responseCode = "400", description = "자기 자신 신고(CANNOT_REPORT_SELF) 또는 ETC 사유에 상세 설명 누락"), + SwaggerApiResponse(responseCode = "404", description = "신고 대상 없음(REPORT_TARGET_NOT_FOUND)"), + SwaggerApiResponse(responseCode = "409", description = "이미 신고한 대상(ALREADY_REPORTED)"), + ) + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + fun createReport( + @LoginUser memberId: Long, + @Valid @RequestBody request: CreateReportRequest, + ): ApiResponse = ApiResponse.success(createReportService.execute(memberId, request)) +} diff --git a/src/main/kotlin/team/cklob/mudda/domain/report/presentation/request/ReportRequests.kt b/src/main/kotlin/team/cklob/mudda/domain/report/presentation/request/ReportRequests.kt new file mode 100644 index 0000000..8e2c0e6 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/report/presentation/request/ReportRequests.kt @@ -0,0 +1,26 @@ +package team.cklob.mudda.domain.report.presentation.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size +import team.cklob.mudda.domain.report.domain.type.ReportReason +import team.cklob.mudda.domain.report.domain.type.ReportTargetType + +@Schema(description = "신고 요청") +data class CreateReportRequest( + @field:NotNull + @Schema(description = "신고 대상 종류", example = "CAPSULE") + val targetType: ReportTargetType?, + + @field:NotNull + @Schema(description = "신고 대상 ID", example = "12") + val targetId: Long?, + + @field:NotNull + @Schema(description = "신고 사유", example = "ABUSE") + val reason: ReportReason?, + + @field:Size(max = 500) + @Schema(description = "상세 사유. reason이 ETC일 때는 필수입니다.", example = "욕설이 포함되어 있습니다.", nullable = true) + val description: String? = null, +) diff --git a/src/main/kotlin/team/cklob/mudda/domain/report/presentation/response/ReportResponses.kt b/src/main/kotlin/team/cklob/mudda/domain/report/presentation/response/ReportResponses.kt new file mode 100644 index 0000000..a64bbd1 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/report/presentation/response/ReportResponses.kt @@ -0,0 +1,35 @@ +package team.cklob.mudda.domain.report.presentation.response + +import io.swagger.v3.oas.annotations.media.Schema +import team.cklob.mudda.domain.report.domain.entity.Report +import team.cklob.mudda.domain.report.domain.type.ReportReason +import team.cklob.mudda.domain.report.domain.type.ReportTargetType +import java.time.LocalDateTime + +@Schema(description = "신고 접수 응답") +data class CreateReportResponse( + @Schema(description = "신고 ID", example = "1") + val reportId: Long, + + @Schema(description = "신고 대상 종류", example = "CAPSULE") + val targetType: ReportTargetType, + + @Schema(description = "신고 대상 ID", example = "12") + val targetId: Long, + + @Schema(description = "신고 사유", example = "ABUSE") + val reason: ReportReason, + + @Schema(description = "접수 시각") + val createdAt: LocalDateTime, +) { + companion object { + fun from(report: Report) = CreateReportResponse( + reportId = requireNotNull(report.id), + targetType = report.targetType, + targetId = report.targetId, + reason = report.reason, + createdAt = report.createdAt, + ) + } +} diff --git a/src/main/kotlin/team/cklob/mudda/global/exception/ErrorCode.kt b/src/main/kotlin/team/cklob/mudda/global/exception/ErrorCode.kt index 2c2ede6..1b662ae 100644 --- a/src/main/kotlin/team/cklob/mudda/global/exception/ErrorCode.kt +++ b/src/main/kotlin/team/cklob/mudda/global/exception/ErrorCode.kt @@ -42,4 +42,9 @@ enum class ErrorCode(val status: HttpStatus, val code: String, val message: Stri BLOCKED_MEMBER(HttpStatus.FORBIDDEN, "F009", "This action is not allowed due to a block relationship."), INVALID_SEARCH_KEYWORD(HttpStatus.BAD_REQUEST, "F010", "Search keyword must not be blank."), NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "N001", "Notification not found."), + CANNOT_BLOCK_SELF(HttpStatus.BAD_REQUEST, "B001", "Cannot block yourself."), + BLOCK_NOT_FOUND(HttpStatus.NOT_FOUND, "B002", "Block not found."), + CANNOT_REPORT_SELF(HttpStatus.BAD_REQUEST, "R001", "Cannot report yourself."), + ALREADY_REPORTED(HttpStatus.CONFLICT, "R002", "You have already reported this target."), + REPORT_TARGET_NOT_FOUND(HttpStatus.NOT_FOUND, "R003", "Report target not found."), } From 0b9739356fe22ae97d539848b0f2af0432d422f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=95=98=EB=AF=BC?= Date: Sun, 30 Aug 2026 12:37:10 +0900 Subject: [PATCH 2/6] feat: #30 :: reclaim unattached media with a scheduled cleanup --- .../application/impl/CleanUpMediaService.kt | 52 +++++++++++++++++++ .../domain/repository/MediaRepository.kt | 8 +++ .../infrastructure/MediaStorageProperties.kt | 3 ++ .../controller/MediaController.kt | 35 ++++++++++++- .../request/CompleteMediaUploadRequest.kt | 6 ++- .../request/CreateMediaUploadUrlRequest.kt | 11 +++- .../response/CompleteMediaUploadResponse.kt | 7 +++ .../response/CreateMediaUploadUrlResponse.kt | 5 ++ .../mudda/global/config/SchedulingConfig.kt | 8 +++ src/main/resources/application.yaml | 4 ++ 10 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 src/main/kotlin/team/cklob/mudda/domain/media/application/impl/CleanUpMediaService.kt create mode 100644 src/main/kotlin/team/cklob/mudda/global/config/SchedulingConfig.kt diff --git a/src/main/kotlin/team/cklob/mudda/domain/media/application/impl/CleanUpMediaService.kt b/src/main/kotlin/team/cklob/mudda/domain/media/application/impl/CleanUpMediaService.kt new file mode 100644 index 0000000..1634e8f --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/media/application/impl/CleanUpMediaService.kt @@ -0,0 +1,52 @@ +package team.cklob.mudda.domain.media.application.impl + +import org.slf4j.LoggerFactory +import org.springframework.data.domain.PageRequest +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import team.cklob.mudda.domain.media.application.MediaStorage +import team.cklob.mudda.domain.media.domain.repository.MediaRepository +import team.cklob.mudda.domain.media.infrastructure.MediaStorageProperties +import java.time.LocalDateTime + +// Reclaims media that was uploaded and registered but never attached to a capsule -- an abandoned compose +// leaves the row and its S3 object behind indefinitely, which V4 made possible when it allowed a null +// time_capsule_id. +// +// ponytail: this only covers media that reached the database. An upload that was signed but never +// completed leaves an orphan under the pending/ prefix with no row to find it by; an S3 lifecycle rule on +// that prefix expires those without any application code, which is the right tool for it. +@Service +class CleanUpMediaService( + private val mediaRepository: MediaRepository, + private val mediaStorage: MediaStorage, + private val properties: MediaStorageProperties, +) { + private val logger = LoggerFactory.getLogger(javaClass) + + @Scheduled(cron = "\${media.cleanup.cron:0 0 4 * * *}") + @Transactional + fun execute(): Int { + val threshold = LocalDateTime.now().minus(properties.pendingRetention) + // Batched so a long-neglected backlog doesn't load every orphan into one transaction; the next run + // picks up whatever is left. + val orphans = mediaRepository.findUnattachedOlderThan(threshold, PageRequest.of(0, BATCH_SIZE)) + if (orphans.isEmpty()) return 0 + + val deleted = orphans.filter { media -> + // The row is only dropped once its object is gone. Deleting the row first on a storage failure + // would strand the object with nothing left pointing at it. + runCatching { mediaStorage.delete(media.s3Key) } + .onFailure { logger.warn("failed to delete an orphaned media object: mediaId={}", media.id, it) } + .isSuccess + } + mediaRepository.deleteAll(deleted) + logger.info("cleaned up {} orphaned media rows (of {} candidates)", deleted.size, orphans.size) + return deleted.size + } + + private companion object { + const val BATCH_SIZE = 500 + } +} diff --git a/src/main/kotlin/team/cklob/mudda/domain/media/domain/repository/MediaRepository.kt b/src/main/kotlin/team/cklob/mudda/domain/media/domain/repository/MediaRepository.kt index ed9f386..362c2d8 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/media/domain/repository/MediaRepository.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/media/domain/repository/MediaRepository.kt @@ -1,17 +1,25 @@ package team.cklob.mudda.domain.media.domain.repository +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 org.springframework.transaction.annotation.Transactional import team.cklob.mudda.domain.media.domain.entity.Media +import java.time.LocalDateTime interface MediaRepository : JpaRepository { fun findByS3KeyAndUploaderId(s3Key: String, uploaderId: Long): Media? fun findByIdAndUploaderId(id: Long, uploaderId: Long): Media? fun findAllByTimeCapsuleId(timeCapsuleId: Long): List + // Media registered through the upload-complete flow but never attached to a capsule. V4 made + // time_capsule_id nullable to allow that intermediate state, which means an abandoned compose leaves + // both the row and its S3 object behind forever. + @Query("SELECT m FROM Media m WHERE m.timeCapsule IS NULL AND m.createdAt < :threshold") + fun findUnattachedOlderThan(@Param("threshold") threshold: LocalDateTime, pageable: Pageable): List + @Modifying @Transactional @Query( diff --git a/src/main/kotlin/team/cklob/mudda/domain/media/infrastructure/MediaStorageProperties.kt b/src/main/kotlin/team/cklob/mudda/domain/media/infrastructure/MediaStorageProperties.kt index f74bc72..d27653b 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/media/infrastructure/MediaStorageProperties.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/media/infrastructure/MediaStorageProperties.kt @@ -12,6 +12,9 @@ data class MediaStorageProperties( val maxImageSize: Long = 10 * 1024 * 1024, val maxVoiceSize: Long = 20 * 1024 * 1024, val maxVideoSize: Long = 100 * 1024 * 1024, + // How long a registered-but-unattached media row is kept before the cleanup job reclaims it. Must stay + // comfortably longer than the time a user might spend composing a capsule after picking their photos. + val pendingRetention: Duration = Duration.ofDays(1), ) { fun maxSizeFor(mediaType: MediaType) = when (mediaType) { MediaType.IMAGE -> maxImageSize diff --git a/src/main/kotlin/team/cklob/mudda/domain/media/presentation/controller/MediaController.kt b/src/main/kotlin/team/cklob/mudda/domain/media/presentation/controller/MediaController.kt index 4492d0d..06d6975 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/media/presentation/controller/MediaController.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/media/presentation/controller/MediaController.kt @@ -1,5 +1,11 @@ package team.cklob.mudda.domain.media.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 @@ -20,6 +26,8 @@ import team.cklob.mudda.domain.media.presentation.response.CreateMediaUploadUrlR import team.cklob.mudda.global.response.ApiResponse import team.cklob.mudda.global.security.LoginUser +@Tag(name = "Media", description = "S3 Presigned URL 기반 미디어 업로드 API") +@SecurityRequirement(name = "bearerAuth") @RestController @RequestMapping("/api/v1/media") class MediaController( @@ -27,6 +35,15 @@ class MediaController( private val completeMediaUploadService: CompleteMediaUploadService, private val deleteMediaService: DeleteMediaService, ) { + @Operation( + summary = "미디어 업로드 URL 발급", + description = "S3에 직접 업로드할 Presigned URL과 업로드 키를 발급합니다. 파일을 해당 URL에 PUT 한 뒤 " + + "`POST /api/v1/media` 로 업로드 완료를 등록해야 실제 미디어로 등록됩니다.", + ) + @SwaggerApiResponses( + SwaggerApiResponse(responseCode = "200", description = "발급 성공"), + SwaggerApiResponse(responseCode = "400", description = "허용되지 않는 확장자·용량(INVALID_MEDIA_UPLOAD)"), + ) @PostMapping("/upload-urls") fun createUploadUrl( @LoginUser memberId: Long, @@ -34,6 +51,16 @@ class MediaController( ): ResponseEntity> = ResponseEntity.ok(ApiResponse.success(createMediaUploadUrlService.execute(memberId, request))) + @Operation( + summary = "미디어 업로드 완료 등록", + description = "Presigned URL 업로드를 마친 뒤 호출합니다. 서버가 S3 객체의 실제 Content-Type과 크기를 재검증한 후 " + + "미디어로 등록합니다. 같은 업로드 키로 다시 호출해도 안전합니다.", + ) + @SwaggerApiResponses( + SwaggerApiResponse(responseCode = "201", description = "등록 성공"), + SwaggerApiResponse(responseCode = "400", description = "업로드 키가 잘못되었거나 검증 실패(INVALID_MEDIA_UPLOAD)"), + SwaggerApiResponse(responseCode = "502", description = "스토리지 요청 실패(MEDIA_STORAGE_ERROR)"), + ) @PostMapping fun completeUpload( @LoginUser memberId: Long, @@ -41,9 +68,15 @@ class MediaController( ): ResponseEntity> = ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(completeMediaUploadService.execute(memberId, request))) + @Operation(summary = "미디어 삭제", description = "아직 캡슐에 첨부되지 않은 본인 소유 미디어만 삭제할 수 있습니다.") + @SwaggerApiResponses( + SwaggerApiResponse(responseCode = "204", description = "삭제 성공"), + SwaggerApiResponse(responseCode = "404", description = "미디어 없음 또는 본인 소유가 아님(MEDIA_NOT_FOUND)"), + SwaggerApiResponse(responseCode = "409", description = "이미 캡슐에 첨부된 미디어(MEDIA_ALREADY_ATTACHED)"), + ) @DeleteMapping("/{mediaId}") @ResponseStatus(HttpStatus.NO_CONTENT) - fun delete(@LoginUser memberId: Long, @PathVariable mediaId: Long) { + fun delete(@LoginUser memberId: Long, @Parameter(description = "삭제할 미디어 ID") @PathVariable mediaId: Long) { deleteMediaService.execute(memberId, mediaId) } } diff --git a/src/main/kotlin/team/cklob/mudda/domain/media/presentation/request/CompleteMediaUploadRequest.kt b/src/main/kotlin/team/cklob/mudda/domain/media/presentation/request/CompleteMediaUploadRequest.kt index e023189..6e2708c 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/media/presentation/request/CompleteMediaUploadRequest.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/media/presentation/request/CompleteMediaUploadRequest.kt @@ -1,7 +1,11 @@ package team.cklob.mudda.domain.media.presentation.request +import io.swagger.v3.oas.annotations.media.Schema import jakarta.validation.constraints.NotBlank +@Schema(description = "미디어 업로드 완료 등록 요청") data class CompleteMediaUploadRequest( - @field:NotBlank val uploadKey: String, + @field:NotBlank + @Schema(description = "업로드 URL 발급 시 함께 받은 업로드 키", example = "1/IMAGE/9f2c...") + val uploadKey: String, ) diff --git a/src/main/kotlin/team/cklob/mudda/domain/media/presentation/request/CreateMediaUploadUrlRequest.kt b/src/main/kotlin/team/cklob/mudda/domain/media/presentation/request/CreateMediaUploadUrlRequest.kt index 3b02592..6b8380b 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/media/presentation/request/CreateMediaUploadUrlRequest.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/media/presentation/request/CreateMediaUploadUrlRequest.kt @@ -1,11 +1,18 @@ package team.cklob.mudda.domain.media.presentation.request +import io.swagger.v3.oas.annotations.media.Schema import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.Positive import team.cklob.mudda.domain.media.domain.type.MediaType +@Schema(description = "미디어 업로드 URL 발급 요청") data class CreateMediaUploadUrlRequest( + @Schema(description = "미디어 종류", example = "IMAGE") val mediaType: MediaType, - @field:NotBlank val contentType: String, - @field:Positive val fileSize: Long, + @field:NotBlank + @Schema(description = "업로드할 파일의 Content-Type. 종류별 허용 목록이 있습니다.", example = "image/jpeg") + val contentType: String, + @field:Positive + @Schema(description = "파일 크기(바이트). 종류별 최대 용량을 넘을 수 없습니다.", example = "204800") + val fileSize: Long, ) diff --git a/src/main/kotlin/team/cklob/mudda/domain/media/presentation/response/CompleteMediaUploadResponse.kt b/src/main/kotlin/team/cklob/mudda/domain/media/presentation/response/CompleteMediaUploadResponse.kt index 86336ca..4527609 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/media/presentation/response/CompleteMediaUploadResponse.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/media/presentation/response/CompleteMediaUploadResponse.kt @@ -1,15 +1,22 @@ package team.cklob.mudda.domain.media.presentation.response +import io.swagger.v3.oas.annotations.media.Schema import team.cklob.mudda.domain.media.application.SignedUrl import team.cklob.mudda.domain.media.domain.entity.Media import team.cklob.mudda.domain.media.domain.type.MediaType import java.time.LocalDateTime +@Schema(description = "미디어 업로드 완료 응답") data class CompleteMediaUploadResponse( + @Schema(description = "미디어 ID", example = "1") val mediaId: Long, + @Schema(description = "조회용 Presigned URL") val accessUrl: String, + @Schema(description = "조회 URL 만료 시각") val accessUrlExpiresAt: LocalDateTime, + @Schema(description = "미디어 종류", example = "IMAGE") val mediaType: MediaType, + @Schema(description = "등록 시각") val createdAt: LocalDateTime, ) { companion object { diff --git a/src/main/kotlin/team/cklob/mudda/domain/media/presentation/response/CreateMediaUploadUrlResponse.kt b/src/main/kotlin/team/cklob/mudda/domain/media/presentation/response/CreateMediaUploadUrlResponse.kt index a949bee..f9960e7 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/media/presentation/response/CreateMediaUploadUrlResponse.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/media/presentation/response/CreateMediaUploadUrlResponse.kt @@ -1,9 +1,14 @@ package team.cklob.mudda.domain.media.presentation.response +import io.swagger.v3.oas.annotations.media.Schema import java.time.LocalDateTime +@Schema(description = "미디어 업로드 URL 발급 응답") data class CreateMediaUploadUrlResponse( + @Schema(description = "업로드 완료 등록 시 그대로 돌려보낼 키", example = "1/IMAGE/9f2c...") val uploadKey: String, + @Schema(description = "이 URL에 파일을 PUT 하면 됩니다.") val uploadUrl: String, + @Schema(description = "업로드 URL 만료 시각") val expiresAt: LocalDateTime, ) diff --git a/src/main/kotlin/team/cklob/mudda/global/config/SchedulingConfig.kt b/src/main/kotlin/team/cklob/mudda/global/config/SchedulingConfig.kt new file mode 100644 index 0000000..fc65591 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/global/config/SchedulingConfig.kt @@ -0,0 +1,8 @@ +package team.cklob.mudda.global.config + +import org.springframework.context.annotation.Configuration +import org.springframework.scheduling.annotation.EnableScheduling + +@Configuration +@EnableScheduling +class SchedulingConfig diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 060849b..d52c0ac 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -69,6 +69,10 @@ media: max-image-size: ${MEDIA_MAX_IMAGE_SIZE:10485760} max-voice-size: ${MEDIA_MAX_VOICE_SIZE:20971520} max-video-size: ${MEDIA_MAX_VIDEO_SIZE:104857600} + pending-retention: ${MEDIA_PENDING_RETENTION:1d} + cleanup: + # Reclaims media rows that were registered but never attached to a capsule. Runs at 04:00 daily. + cron: ${MEDIA_CLEANUP_CRON:0 0 4 * * *} capsule: open-radius-meter: ${CAPSULE_OPEN_RADIUS_METER:100} From 0211dd726bae1e27dc0643057d3d7ad93f9ef7dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=95=98=EB=AF=BC?= Date: Sun, 30 Aug 2026 12:37:10 +0900 Subject: [PATCH 3/6] docs: #30 :: document every endpoint and dto in swagger --- .../presentation/controller/AuthController.kt | 53 +++++- .../presentation/request/LoginAuthRequest.kt | 4 + .../presentation/request/SignupAuthRequest.kt | 6 + .../response/LoginAuthResponse.kt | 5 + .../response/ReissueAuthResponse.kt | 4 + .../controller/MemberController.kt | 26 ++- .../request/UpdateMyMemberRequest.kt | 9 + .../response/MemberProfileResponse.kt | 10 ++ .../presentation/response/MyMemberResponse.kt | 12 ++ .../controller/CapsuleController.kt | 131 +++++++++++++-- .../presentation/request/CapsuleRequests.kt | 83 ++++++++-- .../presentation/response/CapsuleResponses.kt | 154 +++++++++++------- .../mudda/global/config/SwaggerConfig.kt | 27 ++- 13 files changed, 434 insertions(+), 90 deletions(-) diff --git a/src/main/kotlin/team/cklob/mudda/domain/auth/presentation/controller/AuthController.kt b/src/main/kotlin/team/cklob/mudda/domain/auth/presentation/controller/AuthController.kt index be9c85a..968f0e2 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/auth/presentation/controller/AuthController.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/auth/presentation/controller/AuthController.kt @@ -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 @@ -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( @@ -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> = 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> = + fun reissue( + @Parameter(description = "리프레시 토큰. `Bearer ` 접두사를 포함합니다.", example = "Bearer ey...") + @RequestHeader("refreshToken") refreshTokenHeader: String, + ): ResponseEntity> = 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) { diff --git a/src/main/kotlin/team/cklob/mudda/domain/auth/presentation/request/LoginAuthRequest.kt b/src/main/kotlin/team/cklob/mudda/domain/auth/presentation/request/LoginAuthRequest.kt index 5a874f2..be80cae 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/auth/presentation/request/LoginAuthRequest.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/auth/presentation/request/LoginAuthRequest.kt @@ -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, ) diff --git a/src/main/kotlin/team/cklob/mudda/domain/auth/presentation/request/SignupAuthRequest.kt b/src/main/kotlin/team/cklob/mudda/domain/auth/presentation/request/SignupAuthRequest.kt index a64e239..fe924bf 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/auth/presentation/request/SignupAuthRequest.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/auth/presentation/request/SignupAuthRequest.kt @@ -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, ) diff --git a/src/main/kotlin/team/cklob/mudda/domain/auth/presentation/response/LoginAuthResponse.kt b/src/main/kotlin/team/cklob/mudda/domain/auth/presentation/response/LoginAuthResponse.kt index 66528a1..59aad7f 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/auth/presentation/response/LoginAuthResponse.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/auth/presentation/response/LoginAuthResponse.kt @@ -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, ) diff --git a/src/main/kotlin/team/cklob/mudda/domain/auth/presentation/response/ReissueAuthResponse.kt b/src/main/kotlin/team/cklob/mudda/domain/auth/presentation/response/ReissueAuthResponse.kt index d354401..56c0165 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/auth/presentation/response/ReissueAuthResponse.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/auth/presentation/response/ReissueAuthResponse.kt @@ -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, ) diff --git a/src/main/kotlin/team/cklob/mudda/domain/member/presentation/controller/MemberController.kt b/src/main/kotlin/team/cklob/mudda/domain/member/presentation/controller/MemberController.kt index c4c7149..663ac17 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/member/presentation/controller/MemberController.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/member/presentation/controller/MemberController.kt @@ -1,5 +1,11 @@ package team.cklob.mudda.domain.member.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.ResponseEntity import org.springframework.web.bind.annotation.GetMapping @@ -17,6 +23,8 @@ import team.cklob.mudda.domain.member.presentation.response.MyMemberResponse import team.cklob.mudda.global.response.ApiResponse import team.cklob.mudda.global.security.LoginUser +@Tag(name = "Member", description = "내 정보 조회·수정 및 다른 회원 프로필 조회 API") +@SecurityRequirement(name = "bearerAuth") @RestController @RequestMapping("/api/v1/members") class MemberController( @@ -24,19 +32,35 @@ class MemberController( private val updateMyMemberService: UpdateMyMemberService, private val getMemberProfileService: GetMemberProfileService, ) { + @Operation(summary = "내 정보 조회", description = "로그인 사용자 본인의 정보를 조회합니다. 프로필 공개 범위와 무관하게 모든 필드가 반환됩니다.") @GetMapping("/me") fun getMe(@LoginUser memberId: Long): ResponseEntity> = ResponseEntity.ok(ApiResponse.success(getMyMemberService.execute(memberId))) + @Operation(summary = "내 정보 수정", description = "전달된 필드만 부분 수정합니다. 모든 필드를 생략하면 400을 반환합니다.") + @SwaggerApiResponses( + SwaggerApiResponse(responseCode = "200", description = "수정 성공"), + SwaggerApiResponse(responseCode = "400", description = "수정할 필드가 하나도 없음"), + SwaggerApiResponse(responseCode = "409", description = "닉네임 중복(NICKNAME_ALREADY_EXISTS)"), + ) @PatchMapping("/me") fun updateMe( @LoginUser memberId: Long, @Valid @RequestBody request: UpdateMyMemberRequest, ): ResponseEntity> = ResponseEntity.ok(ApiResponse.success(updateMyMemberService.execute(memberId, request))) + @Operation( + summary = "프로필 조회", + description = "다른 회원의 프로필과 나와의 친구 관계 상태를 조회합니다. 상대의 프로필 공개 범위에 따라 접근이 거부될 수 있습니다.", + ) + @SwaggerApiResponses( + SwaggerApiResponse(responseCode = "200", description = "조회 성공"), + SwaggerApiResponse(responseCode = "403", description = "프로필 공개 범위로 접근 불가(PROFILE_ACCESS_DENIED)"), + SwaggerApiResponse(responseCode = "404", description = "회원 없음(MEMBER_NOT_FOUND)"), + ) @GetMapping("/{memberId}") fun getProfile( @LoginUser viewerId: Long, - @PathVariable memberId: Long, + @Parameter(description = "조회할 회원 ID") @PathVariable memberId: Long, ): ResponseEntity> = ResponseEntity.ok(ApiResponse.success(getMemberProfileService.execute(viewerId, memberId))) } diff --git a/src/main/kotlin/team/cklob/mudda/domain/member/presentation/request/UpdateMyMemberRequest.kt b/src/main/kotlin/team/cklob/mudda/domain/member/presentation/request/UpdateMyMemberRequest.kt index 352cd97..be26d53 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/member/presentation/request/UpdateMyMemberRequest.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/member/presentation/request/UpdateMyMemberRequest.kt @@ -1,5 +1,6 @@ package team.cklob.mudda.domain.member.presentation.request +import io.swagger.v3.oas.annotations.media.Schema import jakarta.validation.constraints.Max import jakarta.validation.constraints.Min import jakarta.validation.constraints.Pattern @@ -7,28 +8,36 @@ import jakarta.validation.constraints.Size import team.cklob.mudda.domain.member.domain.type.Gender import team.cklob.mudda.domain.member.domain.type.ProfileVisibility +@Schema(description = "내 정보 수정 요청. 전달한 필드만 수정되며, 모두 생략하면 400을 반환합니다.") data class UpdateMyMemberRequest( @field:Size(max = 30) + @Schema(description = "실명", example = "박하민", nullable = true) val name: String? = null, @field:Size(max = 30) + @Schema(description = "닉네임. 전체에서 유일해야 합니다.", example = "hamin", nullable = true) val nickname: String? = null, + @Schema(description = "성별", example = "MALE", nullable = true) val gender: Gender? = null, @field:Min(1900) @field:Max(2100) + @Schema(description = "출생 연도", example = "2008", nullable = true) val birthYear: Int? = null, // Blank is allowed through here so the service layer's empty-string-to-null clearing still works; // only an actually non-blank, non-http(s) value (e.g. javascript:, data:, file:) is rejected. @field:Pattern(regexp = "^\\s*$|^https?://\\S+$", message = "profileImageUrl must be blank or an http(s) URL") @field:Size(max = 255) + @Schema(description = "프로필 이미지 URL. 빈 문자열을 보내면 기존 이미지가 제거됩니다.", example = "https://cdn.mudda.app/p/1.png", nullable = true) val profileImageUrl: String? = null, @field:Size(max = 100) + @Schema(description = "자기소개", example = "타임캡슐 좋아합니다", nullable = true) val bio: String? = null, + @Schema(description = "프로필 공개 범위", example = "PUBLIC", nullable = true) val profileVisibility: ProfileVisibility? = null, ) { // Add new fields to this comparison too, or an all-null request for the new field would silently pass. diff --git a/src/main/kotlin/team/cklob/mudda/domain/member/presentation/response/MemberProfileResponse.kt b/src/main/kotlin/team/cklob/mudda/domain/member/presentation/response/MemberProfileResponse.kt index 51125c2..1a2a898 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/member/presentation/response/MemberProfileResponse.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/member/presentation/response/MemberProfileResponse.kt @@ -1,18 +1,28 @@ package team.cklob.mudda.domain.member.presentation.response +import io.swagger.v3.oas.annotations.media.Schema import team.cklob.mudda.domain.friend.domain.type.FriendStatus import team.cklob.mudda.domain.member.domain.entity.Member import team.cklob.mudda.domain.member.domain.type.Gender import java.time.LocalDateTime +@Schema(description = "다른 회원 프로필") data class MemberProfileResponse( + @Schema(description = "회원 ID", example = "2") val memberId: Long, + @Schema(description = "닉네임", example = "nick", nullable = true) val nickname: String?, + @Schema(description = "성별", example = "MALE", nullable = true) val gender: Gender?, + @Schema(description = "출생 연도", example = "2008", nullable = true) val birthYear: Int?, + @Schema(description = "프로필 이미지 URL", nullable = true) val profileImageUrl: String?, + @Schema(description = "자기소개", nullable = true) val bio: String?, + @Schema(description = "로그인 사용자와의 친구 관계 상태", example = "FRIEND") val friendStatus: FriendStatus, + @Schema(description = "가입 시각") val createdAt: LocalDateTime, ) { companion object { diff --git a/src/main/kotlin/team/cklob/mudda/domain/member/presentation/response/MyMemberResponse.kt b/src/main/kotlin/team/cklob/mudda/domain/member/presentation/response/MyMemberResponse.kt index 5cb1129..f652209 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/member/presentation/response/MyMemberResponse.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/member/presentation/response/MyMemberResponse.kt @@ -1,20 +1,32 @@ package team.cklob.mudda.domain.member.presentation.response +import io.swagger.v3.oas.annotations.media.Schema import team.cklob.mudda.domain.member.domain.entity.Member import team.cklob.mudda.domain.member.domain.type.Gender import team.cklob.mudda.domain.member.domain.type.ProfileVisibility import java.time.LocalDateTime +@Schema(description = "내 정보") data class MyMemberResponse( + @Schema(description = "회원 ID", example = "1") val memberId: Long, + @Schema(description = "실명", example = "박하민", nullable = true) val name: String?, + @Schema(description = "닉네임", example = "hamin", nullable = true) val nickname: String?, + @Schema(description = "성별", example = "MALE", nullable = true) val gender: Gender?, + @Schema(description = "출생 연도", example = "2008", nullable = true) val birthYear: Int?, + @Schema(description = "프로필 이미지 URL", nullable = true) val profileImageUrl: String?, + @Schema(description = "자기소개", nullable = true) val bio: String?, + @Schema(description = "프로필 공개 범위", example = "PUBLIC") val profileVisibility: ProfileVisibility, + @Schema(description = "가입 시각") val createdAt: LocalDateTime, + @Schema(description = "최종 수정 시각") val updatedAt: LocalDateTime, ) { companion object { diff --git a/src/main/kotlin/team/cklob/mudda/domain/timecapsule/presentation/controller/CapsuleController.kt b/src/main/kotlin/team/cklob/mudda/domain/timecapsule/presentation/controller/CapsuleController.kt index 53db807..a27f30f 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/timecapsule/presentation/controller/CapsuleController.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/timecapsule/presentation/controller/CapsuleController.kt @@ -1,5 +1,11 @@ package team.cklob.mudda.domain.timecapsule.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 @@ -34,6 +40,8 @@ import team.cklob.mudda.domain.timecapsule.presentation.response.GuestbookRespon import team.cklob.mudda.global.response.ApiResponse import team.cklob.mudda.global.security.LoginUser +@Tag(name = "Capsule", description = "타임캡슐 생성·조회·열람·삭제 및 방명록 API") +@SecurityRequirement(name = "bearerAuth") @RestController @RequestMapping("/api/v1/capsule") class CapsuleController( @@ -50,58 +58,151 @@ class CapsuleController( private val updateGuestbookService: UpdateGuestbookService, private val deleteGuestbookService: DeleteGuestbookService, ) { + @Operation( + summary = "타임캡슐 묻기", + description = "지정한 좌표에 캡슐을 묻습니다. 수신자는 친구여야 하며, 첨부 미디어는 본인이 업로드했고 아직 다른 캡슐에 붙지 않은 것이어야 합니다. " + + "잠금 유형에 따라 password 또는 question/answer 조합이 필요합니다.", + ) + @SwaggerApiResponses( + SwaggerApiResponse(responseCode = "201", description = "생성 성공"), + SwaggerApiResponse(responseCode = "400", description = "잠금 조합·공개 시각·만료 시각이 유효하지 않거나, 수신자(INVALID_CAPSULE_RECIPIENT)·미디어(INVALID_CAPSULE_MEDIA)가 부적합"), + SwaggerApiResponse(responseCode = "409", description = "활성 캡슐 개수 한도 초과(CAPSULE_LIMIT_EXCEEDED)"), + ) @PostMapping fun create(@LoginUser memberId: Long, @Valid @RequestBody request: CreateCapsuleRequest): ResponseEntity> = ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(createCapsuleService.execute(memberId, request))) + @Operation(summary = "타임캡슐 목록 조회", description = "로그인 사용자가 접근할 수 있는 캡슐을 최신순으로 조회합니다. 차단한 회원의 캡슐은 제외됩니다.") @GetMapping - fun list(@LoginUser memberId: Long, @RequestParam(defaultValue = "0") page: Int, @RequestParam(defaultValue = "20") size: Int) = + fun list( + @LoginUser memberId: Long, + @Parameter(description = "페이지 번호(0-base)", example = "0") @RequestParam(defaultValue = "0") page: Int, + @Parameter(description = "페이지 크기(1~50)", example = "20") @RequestParam(defaultValue = "20") size: Int, + ) = ApiResponse.success(getCapsuleListService.execute(memberId, page, size)) + @Operation( + summary = "주변 타임캡슐 조회", + description = "좌표 기준 반경 안의 캡슐을 PostGIS 거리 계산으로 가까운 순부터 조회합니다. 캡슐의 정확한 위치는 열람 전까지 노출되지 않습니다.", + ) + @SwaggerApiResponses( + SwaggerApiResponse(responseCode = "200", description = "조회 성공"), + SwaggerApiResponse(responseCode = "400", description = "좌표 범위를 벗어나거나 반경이 허용 최대치를 초과"), + ) @GetMapping("/nearby") fun nearby( @LoginUser memberId: Long, - @RequestParam latitude: Double, - @RequestParam longitude: Double, - @RequestParam radius: Double, - @RequestParam(defaultValue = "0") page: Int, - @RequestParam(defaultValue = "20") size: Int, + @Parameter(description = "기준 위도", example = "37.5") @RequestParam latitude: Double, + @Parameter(description = "기준 경도", example = "127.0") @RequestParam longitude: Double, + @Parameter(description = "검색 반경(미터)", example = "1000") @RequestParam radius: Double, + @Parameter(description = "페이지 번호(0-base)", example = "0") @RequestParam(defaultValue = "0") page: Int, + @Parameter(description = "페이지 크기(1~50)", example = "20") @RequestParam(defaultValue = "20") size: Int, ) = ApiResponse.success(getNearbyCapsuleService.execute(memberId, latitude, longitude, radius, page, size)) + @Operation(summary = "내가 만든 캡슐 목록 조회", description = "로그인 사용자가 묻은 캡슐을 최신순으로 조회합니다.") @GetMapping("/me") - fun mine(@LoginUser memberId: Long, @RequestParam(defaultValue = "0") page: Int, @RequestParam(defaultValue = "20") size: Int) = + fun mine( + @LoginUser memberId: Long, + @Parameter(description = "페이지 번호(0-base)", example = "0") @RequestParam(defaultValue = "0") page: Int, + @Parameter(description = "페이지 크기(1~50)", example = "20") @RequestParam(defaultValue = "20") size: Int, + ) = ApiResponse.success(getMyCapsuleListService.execute(memberId, page, size)) + @Operation(summary = "내가 받은 캡슐 목록 조회", description = "로그인 사용자가 수신자로 지정된 캡슐을 최신순으로 조회합니다.") @GetMapping("/received") - fun received(@LoginUser memberId: Long, @RequestParam(defaultValue = "0") page: Int, @RequestParam(defaultValue = "20") size: Int) = + fun received( + @LoginUser memberId: Long, + @Parameter(description = "페이지 번호(0-base)", example = "0") @RequestParam(defaultValue = "0") page: Int, + @Parameter(description = "페이지 크기(1~50)", example = "20") @RequestParam(defaultValue = "20") size: Int, + ) = ApiResponse.success(getReceivedCapsuleListService.execute(memberId, page, size)) + @Operation( + summary = "타임캡슐 상세 조회", + description = "캡슐의 메타 정보를 조회합니다. 내용은 포함되지 않으며, 열람하려면 `POST /{capsuleId}/open` 을 호출해야 합니다.", + ) + @SwaggerApiResponses( + SwaggerApiResponse(responseCode = "200", description = "조회 성공"), + SwaggerApiResponse(responseCode = "403", description = "공개 범위·차단으로 접근 불가(CAPSULE_ACCESS_DENIED)"), + SwaggerApiResponse(responseCode = "404", description = "캡슐 없음(CAPSULE_NOT_FOUND)"), + SwaggerApiResponse(responseCode = "409", description = "만료된 캡슐(CAPSULE_EXPIRED)"), + ) @GetMapping("/{capsuleId}") - fun detail(@LoginUser memberId: Long, @PathVariable capsuleId: Long) = + fun detail(@LoginUser memberId: Long, @Parameter(description = "캡슐 ID") @PathVariable capsuleId: Long) = ApiResponse.success(getCapsuleDetailService.execute(memberId, capsuleId)) + @Operation( + summary = "캡슐 열람", + description = "현재 위치를 서버에서 PostGIS로 재검증한 뒤 캡슐 내용을 반환합니다. 최초 열람 시에만 잠금(비밀번호·질문)을 검증하며, " + + "재열람은 위치만 다시 검증합니다. 최초 열람은 작성자에게 알림을 보내고, 공개 캡슐이면 발견 피드에 실립니다.", + ) + @SwaggerApiResponses( + SwaggerApiResponse(responseCode = "200", description = "열람 성공"), + SwaggerApiResponse(responseCode = "403", description = "열람 반경 밖(CAPSULE_OUT_OF_RANGE), 잠금 검증 실패(CAPSULE_LOCK_FAILED), 접근 불가(CAPSULE_ACCESS_DENIED)"), + SwaggerApiResponse(responseCode = "404", description = "캡슐 없음(CAPSULE_NOT_FOUND)"), + SwaggerApiResponse(responseCode = "409", description = "아직 공개 시각 전(CAPSULE_NOT_OPEN_YET) 또는 만료됨(CAPSULE_EXPIRED)"), + ) @PostMapping("/{capsuleId}/open") - fun open(@LoginUser memberId: Long, @PathVariable capsuleId: Long, @Valid @RequestBody request: OpenCapsuleRequest) = + fun open(@LoginUser memberId: Long, @Parameter(description = "캡슐 ID") @PathVariable capsuleId: Long, @Valid @RequestBody request: OpenCapsuleRequest) = ApiResponse.success(openCapsuleService.execute(memberId, capsuleId, request)) + @Operation(summary = "타임캡슐 삭제", description = "작성자만 삭제할 수 있으며 소프트 삭제됩니다.") + @SwaggerApiResponses( + SwaggerApiResponse(responseCode = "204", description = "삭제 성공"), + SwaggerApiResponse(responseCode = "403", description = "작성자가 아님(CAPSULE_ACCESS_DENIED)"), + SwaggerApiResponse(responseCode = "404", description = "캡슐 없음(CAPSULE_NOT_FOUND)"), + ) @DeleteMapping("/{capsuleId}") @ResponseStatus(HttpStatus.NO_CONTENT) - fun delete(@LoginUser memberId: Long, @PathVariable capsuleId: Long) = deleteCapsuleService.execute(memberId, capsuleId) + fun delete(@LoginUser memberId: Long, @Parameter(description = "캡슐 ID") @PathVariable capsuleId: Long) = deleteCapsuleService.execute(memberId, capsuleId) + @Operation(summary = "방명록 작성", description = "캡슐을 열람한 사용자만 방명록을 남길 수 있습니다.") + @SwaggerApiResponses( + SwaggerApiResponse(responseCode = "201", description = "작성 성공"), + SwaggerApiResponse(responseCode = "403", description = "아직 열람하지 않은 캡슐(CAPSULE_ACCESS_DENIED)"), + SwaggerApiResponse(responseCode = "404", description = "캡슐 없음(CAPSULE_NOT_FOUND)"), + ) @PostMapping("/{capsuleId}/guestbooks") - fun createGuestbook(@LoginUser memberId: Long, @PathVariable capsuleId: Long, @Valid @RequestBody request: CreateGuestbookRequest): ResponseEntity> = + fun createGuestbook(@LoginUser memberId: Long, @Parameter(description = "캡슐 ID") @PathVariable capsuleId: Long, @Valid @RequestBody request: CreateGuestbookRequest): ResponseEntity> = ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(createGuestbookService.execute(memberId, capsuleId, request))) + @Operation(summary = "방명록 목록 조회", description = "캡슐에 남겨진 방명록을 최신순으로 조회합니다. 삭제된 방명록은 제외됩니다.") @GetMapping("/{capsuleId}/guestbooks") - fun guestbooks(@LoginUser memberId: Long, @PathVariable capsuleId: Long, @RequestParam(defaultValue = "0") page: Int, @RequestParam(defaultValue = "20") size: Int) = + fun guestbooks( + @LoginUser memberId: Long, + @Parameter(description = "캡슐 ID") @PathVariable capsuleId: Long, + @Parameter(description = "페이지 번호(0-base)", example = "0") @RequestParam(defaultValue = "0") page: Int, + @Parameter(description = "페이지 크기(1~50)", example = "20") @RequestParam(defaultValue = "20") size: Int, + ) = ApiResponse.success(getGuestbookListService.execute(memberId, capsuleId, page, size)) + @Operation(summary = "방명록 수정", description = "작성자 본인만 수정할 수 있습니다.") + @SwaggerApiResponses( + SwaggerApiResponse(responseCode = "200", description = "수정 성공"), + SwaggerApiResponse(responseCode = "403", description = "작성자가 아님(GUESTBOOK_ACCESS_DENIED)"), + SwaggerApiResponse(responseCode = "404", description = "방명록 없음(GUESTBOOK_NOT_FOUND)"), + ) @PatchMapping("/{capsuleId}/guestbooks/{guestbookId}") - fun updateGuestbook(@LoginUser memberId: Long, @PathVariable capsuleId: Long, @PathVariable guestbookId: Long, @Valid @RequestBody request: UpdateGuestbookRequest) = + fun updateGuestbook( + @LoginUser memberId: Long, + @Parameter(description = "캡슐 ID") @PathVariable capsuleId: Long, + @Parameter(description = "방명록 ID") @PathVariable guestbookId: Long, + @Valid @RequestBody request: UpdateGuestbookRequest, + ) = ApiResponse.success(updateGuestbookService.execute(memberId, capsuleId, guestbookId, request)) + @Operation(summary = "방명록 삭제", description = "작성자 본인만 삭제할 수 있으며 소프트 삭제됩니다.") + @SwaggerApiResponses( + SwaggerApiResponse(responseCode = "204", description = "삭제 성공"), + SwaggerApiResponse(responseCode = "403", description = "작성자가 아님(GUESTBOOK_ACCESS_DENIED)"), + SwaggerApiResponse(responseCode = "404", description = "방명록 없음(GUESTBOOK_NOT_FOUND)"), + ) @DeleteMapping("/{capsuleId}/guestbooks/{guestbookId}") @ResponseStatus(HttpStatus.NO_CONTENT) - fun deleteGuestbook(@LoginUser memberId: Long, @PathVariable capsuleId: Long, @PathVariable guestbookId: Long) = + fun deleteGuestbook( + @LoginUser memberId: Long, + @Parameter(description = "캡슐 ID") @PathVariable capsuleId: Long, + @Parameter(description = "방명록 ID") @PathVariable guestbookId: Long, + ) = deleteGuestbookService.execute(memberId, capsuleId, guestbookId) } diff --git a/src/main/kotlin/team/cklob/mudda/domain/timecapsule/presentation/request/CapsuleRequests.kt b/src/main/kotlin/team/cklob/mudda/domain/timecapsule/presentation/request/CapsuleRequests.kt index d951dc9..17a02af 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/timecapsule/presentation/request/CapsuleRequests.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/timecapsule/presentation/request/CapsuleRequests.kt @@ -1,5 +1,6 @@ package team.cklob.mudda.domain.timecapsule.presentation.request +import io.swagger.v3.oas.annotations.media.Schema import jakarta.validation.constraints.DecimalMax import jakarta.validation.constraints.DecimalMin import jakarta.validation.constraints.NotBlank @@ -9,28 +10,86 @@ import team.cklob.mudda.domain.timecapsule.domain.type.CapsuleLockType import team.cklob.mudda.domain.timecapsule.domain.type.CapsuleVisibility import java.time.LocalDateTime +@Schema( + description = "타임캡슐 생성 요청. lockType에 따라 잠금 필드 조합이 정해집니다 — " + + "NONE이면 password·question·answer가 모두 없어야 하고, PASSWORD면 password만, QUESTION이면 question과 answer가 함께 필요합니다.", +) data class CreateCapsuleRequest( - @field:NotBlank @field:Size(max = 255) val name: String, - @field:NotBlank val content: String, - @field:DecimalMin("-90.0") @field:DecimalMax("90.0") val latitude: Double, - @field:DecimalMin("-180.0") @field:DecimalMax("180.0") val longitude: Double, - @field:NotNull val openAt: LocalDateTime, + @field:NotBlank @field:Size(max = 255) + @Schema(description = "캡슐 제목", example = "첫 캡슐") + val name: String, + + @field:NotBlank + @Schema(description = "캡슐 내용. 저장 시 AES-256-GCM으로 암호화됩니다.", example = "10년 뒤의 나에게") + val content: String, + + @field:DecimalMin("-90.0") @field:DecimalMax("90.0") + @Schema(description = "캡슐을 묻을 위도", example = "37.5") + val latitude: Double, + + @field:DecimalMin("-180.0") @field:DecimalMax("180.0") + @Schema(description = "캡슐을 묻을 경도", example = "127.0") + val longitude: Double, + + @field:NotNull + @Schema(description = "열람 가능해지는 시각. 현재보다 미래여야 합니다.", example = "2027-01-01T00:00:00") + val openAt: LocalDateTime, + + @Schema(description = "만료 시각. openAt 이후여야 하며 최대 허용 연수를 넘을 수 없습니다.", example = "2030-01-01T00:00:00", nullable = true) val expiredAt: LocalDateTime? = null, - @field:NotNull val visibility: CapsuleVisibility, - @field:NotNull val lockType: CapsuleLockType, + + @field:NotNull + @Schema(description = "공개 범위", example = "PUBLIC") + val visibility: CapsuleVisibility, + + @field:NotNull + @Schema(description = "잠금 유형", example = "NONE") + val lockType: CapsuleLockType, + + @Schema(description = "lockType이 PASSWORD일 때의 비밀번호", nullable = true) val password: String? = null, - @field:Size(max = 255) val question: String? = null, + + @field:Size(max = 255) + @Schema(description = "lockType이 QUESTION일 때의 질문", example = "우리가 처음 만난 곳은?", nullable = true) + val question: String? = null, + + @Schema(description = "lockType이 QUESTION일 때의 정답. 대소문자와 앞뒤 공백은 무시됩니다.", nullable = true) val answer: String? = null, + + @Schema(description = "캡슐을 받을 회원 ID 목록. 친구 관계이면서 차단되지 않은 회원이어야 합니다.", example = "[2, 3]") val recipientIds: Set = emptySet(), + + @Schema(description = "첨부할 미디어 ID 목록. 본인이 업로드했고 아직 다른 캡슐에 붙지 않은 것이어야 합니다.", example = "[10]") val mediaIds: Set = emptySet(), ) +@Schema(description = "캡슐 열람 요청. 좌표는 서버에서 PostGIS로 재검증합니다.") data class OpenCapsuleRequest( - @field:DecimalMin("-90.0") @field:DecimalMax("90.0") val latitude: Double, - @field:DecimalMin("-180.0") @field:DecimalMax("180.0") val longitude: Double, + @field:DecimalMin("-90.0") @field:DecimalMax("90.0") + @Schema(description = "현재 위도", example = "37.5") + val latitude: Double, + + @field:DecimalMin("-180.0") @field:DecimalMax("180.0") + @Schema(description = "현재 경도", example = "127.0") + val longitude: Double, + + @Schema(description = "lockType이 PASSWORD인 캡슐의 비밀번호. 최초 열람 시에만 검증합니다.", nullable = true) val password: String? = null, + + @Schema(description = "lockType이 QUESTION인 캡슐의 정답. 최초 열람 시에만 검증합니다.", nullable = true) val answer: String? = null, ) -data class CreateGuestbookRequest(@field:NotBlank val content: String) -data class UpdateGuestbookRequest(@field:NotBlank val content: String) +@Schema(description = "방명록 작성 요청") +data class CreateGuestbookRequest( + @field:NotBlank + @Schema(description = "방명록 내용", example = "다녀갑니다") + val content: String, +) + +@Schema(description = "방명록 수정 요청") +data class UpdateGuestbookRequest( + @field:NotBlank + @Schema(description = "수정할 방명록 내용", example = "다시 다녀갑니다") + val content: String, +) diff --git a/src/main/kotlin/team/cklob/mudda/domain/timecapsule/presentation/response/CapsuleResponses.kt b/src/main/kotlin/team/cklob/mudda/domain/timecapsule/presentation/response/CapsuleResponses.kt index 5519ffa..eb5c8a2 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/timecapsule/presentation/response/CapsuleResponses.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/timecapsule/presentation/response/CapsuleResponses.kt @@ -1,86 +1,122 @@ package team.cklob.mudda.domain.timecapsule.presentation.response +import io.swagger.v3.oas.annotations.media.Schema import team.cklob.mudda.domain.media.domain.type.MediaType import team.cklob.mudda.domain.timecapsule.domain.type.CapsuleLockType import team.cklob.mudda.domain.timecapsule.domain.type.CapsuleVisibility import java.time.LocalDateTime -data class WriterResponse(val memberId: Long, val nickname: String?, val profileImageUrl: String?) -data class MediaResponse(val mediaId: Long, val url: String, val type: MediaType) +@Schema(description = "캡슐 작성자") +data class WriterResponse( + @Schema(description = "회원 ID", example = "1") val memberId: Long, + @Schema(description = "닉네임", example = "nick", nullable = true) val nickname: String?, + @Schema(description = "프로필 이미지 URL", nullable = true) val profileImageUrl: String?, +) + +@Schema(description = "캡슐에 첨부된 미디어") +data class MediaResponse( + @Schema(description = "미디어 ID", example = "10") val mediaId: Long, + @Schema(description = "조회용 Presigned URL") val url: String, + @Schema(description = "미디어 종류", example = "IMAGE") val type: MediaType, +) +@Schema(description = "타임캡슐 생성 응답") data class CreateCapsuleResponse( - val capsuleId: Long, - val title: String, - val latitude: Double, - val longitude: Double, - val openAt: LocalDateTime, - val expiredAt: LocalDateTime?, - val createdAt: LocalDateTime, + @Schema(description = "캡슐 ID", example = "1") val capsuleId: Long, + @Schema(description = "캡슐 제목", example = "첫 캡슐") val title: String, + @Schema(description = "위도", example = "37.5") val latitude: Double, + @Schema(description = "경도", example = "127.0") val longitude: Double, + @Schema(description = "열람 가능 시각") val openAt: LocalDateTime, + @Schema(description = "만료 시각", nullable = true) val expiredAt: LocalDateTime?, + @Schema(description = "생성 시각") val createdAt: LocalDateTime, ) +@Schema(description = "타임캡슐 목록 항목") data class CapsuleListItemResponse( - val capsuleId: Long, - val title: String, - val writer: WriterResponse?, - val latitude: Double, - val longitude: Double, - val openAt: LocalDateTime, - val expiredAt: LocalDateTime?, - val visibility: CapsuleVisibility, - val requiresPassword: Boolean, - val requiresAnswer: Boolean, - val isOpened: Boolean, - val createdAt: LocalDateTime, + @Schema(description = "캡슐 ID", example = "1") val capsuleId: Long, + @Schema(description = "캡슐 제목", example = "첫 캡슐") val title: String, + @Schema(description = "작성자. 내가 만든 캡슐 목록에서는 생략됩니다.", nullable = true) val writer: WriterResponse?, + @Schema(description = "위도", example = "37.5") val latitude: Double, + @Schema(description = "경도", example = "127.0") val longitude: Double, + @Schema(description = "열람 가능 시각") val openAt: LocalDateTime, + @Schema(description = "만료 시각", nullable = true) val expiredAt: LocalDateTime?, + @Schema(description = "공개 범위", example = "PUBLIC") val visibility: CapsuleVisibility, + @Schema(description = "열람에 비밀번호가 필요한지 여부", example = "false") val requiresPassword: Boolean, + @Schema(description = "열람에 질문 응답이 필요한지 여부", example = "false") val requiresAnswer: Boolean, + @Schema(description = "로그인 사용자가 이미 열어본 캡슐인지 여부", example = "false") val isOpened: Boolean, + @Schema(description = "생성 시각") val createdAt: LocalDateTime, ) +@Schema(description = "주변 타임캡슐 항목") data class NearbyCapsuleResponse( - val capsuleId: Long, - val title: String, - val latitude: Double, - val longitude: Double, - val distance: Double, - val requiredDistance: Double, - val openAt: LocalDateTime, - val lockType: CapsuleLockType, - val isOpened: Boolean, + @Schema(description = "캡슐 ID", example = "1") val capsuleId: Long, + @Schema(description = "캡슐 제목", example = "첫 캡슐") val title: String, + @Schema(description = "위도", example = "37.5") val latitude: Double, + @Schema(description = "경도", example = "127.0") val longitude: Double, + @Schema(description = "현재 위치로부터의 거리(미터)", example = "42.7") val distance: Double, + @Schema(description = "열람하려면 들어가야 하는 반경(미터)", example = "100.0") val requiredDistance: Double, + @Schema(description = "열람 가능 시각") val openAt: LocalDateTime, + @Schema(description = "잠금 유형", example = "NONE") val lockType: CapsuleLockType, + @Schema(description = "로그인 사용자가 이미 열어본 캡슐인지 여부", example = "false") val isOpened: Boolean, ) +@Schema(description = "타임캡슐 상세. 내용은 포함되지 않으며 열람 API로만 얻을 수 있습니다.") data class CapsuleDetailResponse( - val capsuleId: Long, - val title: String, - val writer: WriterResponse, - val latitude: Double, - val longitude: Double, - val requiredDistance: Double, - val openAt: LocalDateTime, - val expiredAt: LocalDateTime?, - val visibility: CapsuleVisibility, - val lockType: CapsuleLockType, - val question: String?, - val isOpened: Boolean, - val createdAt: LocalDateTime, - val updatedAt: LocalDateTime, + @Schema(description = "캡슐 ID", example = "1") val capsuleId: Long, + @Schema(description = "캡슐 제목", example = "첫 캡슐") val title: String, + @Schema(description = "작성자") val writer: WriterResponse, + @Schema(description = "위도", example = "37.5") val latitude: Double, + @Schema(description = "경도", example = "127.0") val longitude: Double, + @Schema(description = "열람하려면 들어가야 하는 반경(미터)", example = "100.0") val requiredDistance: Double, + @Schema(description = "열람 가능 시각") val openAt: LocalDateTime, + @Schema(description = "만료 시각", nullable = true) val expiredAt: LocalDateTime?, + @Schema(description = "공개 범위", example = "PUBLIC") val visibility: CapsuleVisibility, + @Schema(description = "잠금 유형", example = "QUESTION") val lockType: CapsuleLockType, + @Schema(description = "lockType이 QUESTION일 때의 질문", nullable = true) val question: String?, + @Schema(description = "로그인 사용자가 이미 열어본 캡슐인지 여부", example = "false") val isOpened: Boolean, + @Schema(description = "생성 시각") val createdAt: LocalDateTime, + @Schema(description = "최종 수정 시각") val updatedAt: LocalDateTime, ) +@Schema(description = "캡슐 열람 응답") data class OpenCapsuleResponse( - val capsuleId: Long, - val title: String, - val content: String, - val writer: WriterResponse, - val media: List, - val openedAt: LocalDateTime, + @Schema(description = "캡슐 ID", example = "1") val capsuleId: Long, + @Schema(description = "캡슐 제목", example = "첫 캡슐") val title: String, + @Schema(description = "복호화된 캡슐 내용") val content: String, + @Schema(description = "작성자") val writer: WriterResponse, + @Schema(description = "첨부 미디어 목록") val media: List, + @Schema(description = "최초 열람 시각. 재열람해도 갱신되지 않습니다.") val openedAt: LocalDateTime, ) -data class CapsulePageResponse(val capsules: List, val page: Int, val size: Int, val totalCount: Long) +@Schema(description = "타임캡슐 페이지 응답") +data class CapsulePageResponse( + @Schema(description = "캡슐 목록") val capsules: List, + @Schema(description = "현재 페이지 번호(0-base)", example = "0") val page: Int, + @Schema(description = "페이지 크기", example = "20") val size: Int, + @Schema(description = "전체 캡슐 수", example = "42") val totalCount: Long, +) +@Schema(description = "방명록") data class GuestbookResponse( - val guestbookId: Long, - val capsuleId: Long, - val writer: WriterResponse, - val content: String, - val createdAt: LocalDateTime, - val updatedAt: LocalDateTime, + @Schema(description = "방명록 ID", example = "1") val guestbookId: Long, + @Schema(description = "캡슐 ID", example = "1") val capsuleId: Long, + @Schema(description = "작성자") val writer: WriterResponse, + @Schema(description = "방명록 내용", example = "다녀갑니다") val content: String, + @Schema(description = "작성 시각") val createdAt: LocalDateTime, + @Schema(description = "최종 수정 시각") val updatedAt: LocalDateTime, ) -data class GuestbookPageResponse(val guestbooks: List, val page: Int, val size: Int, val totalCount: Long) -data class UpdateGuestbookResponse(val guestbookId: Long, val content: String, val updatedAt: LocalDateTime) +@Schema(description = "방명록 페이지 응답") +data class GuestbookPageResponse( + @Schema(description = "방명록 목록") val guestbooks: List, + @Schema(description = "현재 페이지 번호(0-base)", example = "0") val page: Int, + @Schema(description = "페이지 크기", example = "20") val size: Int, + @Schema(description = "전체 방명록 수", example = "5") val totalCount: Long, +) + +@Schema(description = "방명록 수정 응답") +data class UpdateGuestbookResponse( + @Schema(description = "방명록 ID", example = "1") val guestbookId: Long, + @Schema(description = "수정된 내용", example = "다시 다녀갑니다") val content: String, + @Schema(description = "수정 시각") val updatedAt: LocalDateTime, +) diff --git a/src/main/kotlin/team/cklob/mudda/global/config/SwaggerConfig.kt b/src/main/kotlin/team/cklob/mudda/global/config/SwaggerConfig.kt index 52f2efc..be57035 100644 --- a/src/main/kotlin/team/cklob/mudda/global/config/SwaggerConfig.kt +++ b/src/main/kotlin/team/cklob/mudda/global/config/SwaggerConfig.kt @@ -2,11 +2,36 @@ package team.cklob.mudda.global.config import io.swagger.v3.oas.models.Components import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.info.Info import io.swagger.v3.oas.models.security.SecurityScheme import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @Configuration class SwaggerConfig { - @Bean fun openAPI(): OpenAPI = OpenAPI().components(Components().addSecuritySchemes("bearerAuth", SecurityScheme().type(SecurityScheme.Type.HTTP).scheme("bearer").bearerFormat("JWT"))) + @Bean + fun openAPI(): OpenAPI = OpenAPI() + .info( + Info() + .title("MUDDA API") + .version("v1") + .description( + """ + 위치 잠금 타임캡슐 서비스 API입니다. + + **인증** — `/api/v1/auth/oauth/{provider}` 로 발급받은 액세스 토큰을 우측 상단 Authorize 에 입력하면 + 인증이 필요한 API를 그대로 호출할 수 있습니다. `auth/oauth`, `auth/reissue` 외의 모든 엔드포인트는 인증이 필요합니다. + + **응답 형식** — 모든 응답은 `{ "success": true, "data": ... }` 또는 + `{ "success": false, "error": { "code": "...", "message": "..." } }` 형태로 감싸집니다. + """.trimIndent(), + ), + ) + .components( + Components().addSecuritySchemes( + "bearerAuth", + SecurityScheme().type(SecurityScheme.Type.HTTP).scheme("bearer").bearerFormat("JWT") + .description("발급받은 액세스 토큰. `Bearer ` 접두사는 자동으로 붙습니다."), + ), + ) } From bbacbf03cdf91925560bb2761f0f7f1d82198dee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=95=98=EB=AF=BC?= Date: Sun, 30 Aug 2026 12:37:10 +0900 Subject: [PATCH 4/6] test: #30 :: cover block, report, media cleanup, and the openapi document --- .../application/impl/BlockServicesTest.kt | 80 +++++++++++++++ .../impl/CleanUpMediaServiceTest.kt | 67 +++++++++++++ .../impl/CreateReportServiceTest.kt | 99 +++++++++++++++++++ .../global/config/OpenApiDocumentTest.kt | 89 +++++++++++++++++ 4 files changed, 335 insertions(+) create mode 100644 src/test/kotlin/team/cklob/mudda/domain/block/application/impl/BlockServicesTest.kt create mode 100644 src/test/kotlin/team/cklob/mudda/domain/media/application/impl/CleanUpMediaServiceTest.kt create mode 100644 src/test/kotlin/team/cklob/mudda/domain/report/application/impl/CreateReportServiceTest.kt create mode 100644 src/test/kotlin/team/cklob/mudda/global/config/OpenApiDocumentTest.kt diff --git a/src/test/kotlin/team/cklob/mudda/domain/block/application/impl/BlockServicesTest.kt b/src/test/kotlin/team/cklob/mudda/domain/block/application/impl/BlockServicesTest.kt new file mode 100644 index 0000000..0632ce9 --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/domain/block/application/impl/BlockServicesTest.kt @@ -0,0 +1,80 @@ +package team.cklob.mudda.domain.block.application.impl + +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import team.cklob.mudda.domain.block.domain.entity.Block +import team.cklob.mudda.domain.block.domain.repository.BlockRepository +import team.cklob.mudda.domain.block.presentation.request.CreateBlockRequest +import team.cklob.mudda.domain.member.domain.entity.Member +import team.cklob.mudda.domain.member.domain.repository.MemberRepository +import team.cklob.mudda.domain.member.domain.type.OAuthProvider +import team.cklob.mudda.domain.member.domain.type.ProfileVisibility +import team.cklob.mudda.global.exception.BusinessException +import team.cklob.mudda.global.exception.ErrorCode +import java.time.LocalDateTime +import java.util.Optional +import kotlin.test.assertEquals + +class BlockServicesTest { + private val blockRepository = mockk() + private val memberRepository = mockk() + private val createService = CreateBlockService(blockRepository, memberRepository) + private val deleteService = DeleteBlockService(blockRepository) + + private fun member(id: Long, withdrawnAt: LocalDateTime? = null) = Member( + name = "name", nickname = "nick$id", email = "a$id@example.com", oauthProvider = OAuthProvider.GOOGLE, + providerId = "provider$id", profileVisibility = ProfileVisibility.PUBLIC, withdrawnAt = withdrawnAt, id = id, + ) + + @Test fun `blocking yourself is rejected`() { + val error = assertThrows { createService.execute(1, CreateBlockRequest(1)) } + + assertEquals(ErrorCode.CANNOT_BLOCK_SELF, error.errorCode) + } + + @Test fun `blocking a withdrawn member reports the member as missing`() { + every { memberRepository.findById(2) } returns Optional.of(member(2, withdrawnAt = LocalDateTime.now())) + + val error = assertThrows { createService.execute(1, CreateBlockRequest(2)) } + + assertEquals(ErrorCode.MEMBER_NOT_FOUND, error.errorCode) + } + + // Blocking twice lands on the same end state, so it returns the existing row rather than a conflict + // the client would have to special-case. + @Test fun `blocking an already blocked member is idempotent`() { + val existing = Block(blocker = member(1), blocked = member(2), id = 9) + every { memberRepository.findById(2) } returns Optional.of(member(2)) + every { blockRepository.findByBlockerIdAndBlockedId(1, 2) } returns Optional.of(existing) + + val response = createService.execute(1, CreateBlockRequest(2)) + + assertEquals(9, response.blockId) + verify(exactly = 0) { blockRepository.save(any()) } + } + + // The whole block policy rests on read-path filtering: nothing is deleted, so unblocking restores the + // prior friendship and pending requests for free. + @Test fun `blocking writes one row and deletes nothing`() { + every { memberRepository.findById(2) } returns Optional.of(member(2)) + every { memberRepository.findById(1) } returns Optional.of(member(1)) + every { blockRepository.findByBlockerIdAndBlockedId(1, 2) } returns Optional.empty() + every { blockRepository.save(any()) } answers { Block(member(1), member(2), id = 5) } + + createService.execute(1, CreateBlockRequest(2)) + + verify(exactly = 1) { blockRepository.save(any()) } + verify(exactly = 0) { blockRepository.delete(any()) } + } + + @Test fun `unblocking a member that was never blocked reports not found`() { + every { blockRepository.findByBlockerIdAndBlockedId(1, 2) } returns Optional.empty() + + val error = assertThrows { deleteService.execute(1, 2) } + + assertEquals(ErrorCode.BLOCK_NOT_FOUND, error.errorCode) + } +} diff --git a/src/test/kotlin/team/cklob/mudda/domain/media/application/impl/CleanUpMediaServiceTest.kt b/src/test/kotlin/team/cklob/mudda/domain/media/application/impl/CleanUpMediaServiceTest.kt new file mode 100644 index 0000000..fd5f070 --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/domain/media/application/impl/CleanUpMediaServiceTest.kt @@ -0,0 +1,67 @@ +package team.cklob.mudda.domain.media.application.impl + +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.Test +import team.cklob.mudda.domain.media.application.MediaStorage +import team.cklob.mudda.domain.media.domain.entity.Media +import team.cklob.mudda.domain.media.domain.repository.MediaRepository +import team.cklob.mudda.domain.media.domain.type.MediaType +import team.cklob.mudda.domain.media.infrastructure.MediaStorageProperties +import team.cklob.mudda.domain.member.domain.entity.Member +import team.cklob.mudda.domain.member.domain.type.OAuthProvider +import team.cklob.mudda.domain.member.domain.type.ProfileVisibility +import team.cklob.mudda.global.exception.BusinessException +import team.cklob.mudda.global.exception.ErrorCode +import kotlin.test.assertEquals + +class CleanUpMediaServiceTest { + private val mediaRepository = mockk() + private val mediaStorage = mockk() + private val service = CleanUpMediaService(mediaRepository, mediaStorage, MediaStorageProperties(bucket = "bucket")) + + private val uploader = Member( + name = "name", nickname = "nick", email = "a@example.com", oauthProvider = OAuthProvider.GOOGLE, + providerId = "provider", profileVisibility = ProfileVisibility.PUBLIC, id = 1, + ) + + private fun media(id: Long, key: String) = Media(uploader, null, MediaType.IMAGE, key, id) + + @Test fun `nothing is deleted when there are no orphans`() { + every { mediaRepository.findUnattachedOlderThan(any(), any()) } returns emptyList() + + assertEquals(0, service.execute()) + + verify(exactly = 0) { mediaStorage.delete(any()) } + } + + // The row is the only pointer to the object, so dropping it after a failed storage delete would strand + // the object with nothing left to find it by. + @Test fun `a row whose object could not be deleted is kept for the next run`() { + val ok = media(1, "key-ok") + val failing = media(2, "key-failing") + every { mediaRepository.findUnattachedOlderThan(any(), any()) } returns listOf(ok, failing) + every { mediaStorage.delete("key-ok") } returns Unit + every { mediaStorage.delete("key-failing") } throws BusinessException(ErrorCode.MEDIA_STORAGE_ERROR) + val deleted = slot>() + every { mediaRepository.deleteAll(capture(deleted)) } returns Unit + + assertEquals(1, service.execute()) + + assertEquals(listOf(ok), deleted.captured) + } + + @Test fun `every orphan whose object is gone has its row removed`() { + val orphans = listOf(media(1, "a"), media(2, "b")) + every { mediaRepository.findUnattachedOlderThan(any(), any()) } returns orphans + every { mediaStorage.delete(any()) } returns Unit + val deleted = slot>() + every { mediaRepository.deleteAll(capture(deleted)) } returns Unit + + assertEquals(2, service.execute()) + + assertEquals(orphans, deleted.captured) + } +} diff --git a/src/test/kotlin/team/cklob/mudda/domain/report/application/impl/CreateReportServiceTest.kt b/src/test/kotlin/team/cklob/mudda/domain/report/application/impl/CreateReportServiceTest.kt new file mode 100644 index 0000000..d7d1405 --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/domain/report/application/impl/CreateReportServiceTest.kt @@ -0,0 +1,99 @@ +package team.cklob.mudda.domain.report.application.impl + +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.locationtech.jts.geom.Coordinate +import org.locationtech.jts.geom.GeometryFactory +import team.cklob.mudda.domain.member.domain.entity.Member +import team.cklob.mudda.domain.member.domain.repository.MemberRepository +import team.cklob.mudda.domain.member.domain.type.OAuthProvider +import team.cklob.mudda.domain.member.domain.type.ProfileVisibility +import team.cklob.mudda.domain.report.domain.entity.Report +import team.cklob.mudda.domain.report.domain.repository.ReportRepository +import team.cklob.mudda.domain.report.domain.type.ReportReason +import team.cklob.mudda.domain.report.domain.type.ReportTargetType +import team.cklob.mudda.domain.report.presentation.request.CreateReportRequest +import team.cklob.mudda.domain.timecapsule.domain.entity.TimeCapsule +import team.cklob.mudda.domain.timecapsule.domain.repository.GuestbookRepository +import team.cklob.mudda.domain.timecapsule.domain.repository.TimeCapsuleRepository +import team.cklob.mudda.domain.timecapsule.domain.type.CapsuleLockType +import team.cklob.mudda.domain.timecapsule.domain.type.CapsuleVisibility +import team.cklob.mudda.global.exception.BusinessException +import team.cklob.mudda.global.exception.ErrorCode +import java.time.LocalDateTime +import java.util.Optional +import kotlin.test.assertEquals + +class CreateReportServiceTest { + private val reportRepository = mockk() + private val memberRepository = mockk() + private val capsuleRepository = mockk() + private val guestbookRepository = mockk() + private val service = CreateReportService(reportRepository, memberRepository, capsuleRepository, guestbookRepository) + + private fun member(id: Long) = Member( + name = "name", nickname = "nick$id", email = "a$id@example.com", oauthProvider = OAuthProvider.GOOGLE, + providerId = "provider$id", profileVisibility = ProfileVisibility.PUBLIC, id = id, + ) + + private fun capsule(isDeleted: Boolean = false) = TimeCapsule( + member = member(2), name = "capsule", visibility = CapsuleVisibility.PUBLIC, lockType = CapsuleLockType.NONE, + location = GeometryFactory().createPoint(Coordinate(127.0, 37.5)), openRadiusMeter = 100, + openAt = LocalDateTime.now(), isDeleted = isDeleted, id = 5, + ) + + @Test fun `reporting yourself is rejected`() { + val request = CreateReportRequest(ReportTargetType.MEMBER, 1, ReportReason.ABUSE) + + val error = assertThrows { service.execute(1, request) } + + assertEquals(ErrorCode.CANNOT_REPORT_SELF, error.errorCode) + } + + // ETC carries no meaning on its own; without a description the report is unactionable for a reviewer. + @Test fun `ETC without a description is rejected`() { + val request = CreateReportRequest(ReportTargetType.CAPSULE, 5, ReportReason.ETC, description = " ") + + val error = assertThrows { service.execute(1, request) } + + assertEquals(ErrorCode.INVALID_INPUT, error.errorCode) + } + + @Test fun `reporting a deleted capsule reports the target as missing`() { + every { capsuleRepository.findById(5) } returns Optional.of(capsule(isDeleted = true)) + val request = CreateReportRequest(ReportTargetType.CAPSULE, 5, ReportReason.ABUSE) + + val error = assertThrows { service.execute(1, request) } + + assertEquals(ErrorCode.REPORT_TARGET_NOT_FOUND, error.errorCode) + } + + @Test fun `reporting the same target twice is a conflict`() { + every { capsuleRepository.findById(5) } returns Optional.of(capsule()) + every { reportRepository.existsByReporterIdAndTargetTypeAndTargetId(1, ReportTargetType.CAPSULE, 5) } returns true + val request = CreateReportRequest(ReportTargetType.CAPSULE, 5, ReportReason.ABUSE) + + val error = assertThrows { service.execute(1, request) } + + assertEquals(ErrorCode.ALREADY_REPORTED, error.errorCode) + verify(exactly = 0) { reportRepository.save(any()) } + } + + @Test fun `a valid capsule report is stored`() { + every { capsuleRepository.findById(5) } returns Optional.of(capsule()) + every { reportRepository.existsByReporterIdAndTargetTypeAndTargetId(1, ReportTargetType.CAPSULE, 5) } returns false + every { memberRepository.findById(1) } returns Optional.of(member(1)) + every { reportRepository.save(any()) } answers { + Report(member(1), ReportTargetType.CAPSULE, 5, ReportReason.ABUSE, "욕설", id = 7) + } + val request = CreateReportRequest(ReportTargetType.CAPSULE, 5, ReportReason.ABUSE, "욕설") + + val response = service.execute(1, request) + + assertEquals(7, response.reportId) + assertEquals(ReportReason.ABUSE, response.reason) + } +} diff --git a/src/test/kotlin/team/cklob/mudda/global/config/OpenApiDocumentTest.kt b/src/test/kotlin/team/cklob/mudda/global/config/OpenApiDocumentTest.kt new file mode 100644 index 0000000..3a113c4 --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/global/config/OpenApiDocumentTest.kt @@ -0,0 +1,89 @@ +package team.cklob.mudda.global.config + +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import team.cklob.mudda.support.PostgresIntegrationTest +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +// Swagger annotations are easy to add and just as easy to leave half-finished, so the generated document +// is asserted directly rather than trusting that the annotations are present in the source. +@AutoConfigureMockMvc +class OpenApiDocumentTest( + @Autowired private val mockMvc: MockMvc, + @Autowired private val objectMapper: ObjectMapper, +) : PostgresIntegrationTest() { + private fun document(): JsonNode { + val body = mockMvc.perform(get("/v3/api-docs")).andReturn().response.contentAsString + return objectMapper.readTree(body) + } + + // SecurityConfigTest declares a @RestController fixture that component scanning pulls into this + // context, so it appears in the generated document during tests but never in production. It is not + // part of the API under test. + private fun JsonNode.productionOperations(): List> = + path("paths").fields().asSequence().flatMap { (path, methods) -> + methods.fields().asSequence().map { (method, operation) -> "$method $path" to operation } + }.filterNot { (_, operation) -> + operation.path("tags").any { it.asText() in TEST_ONLY_TAGS } + }.toList() + + private companion object { + val TEST_ONLY_TAGS = setOf("security-test-controller") + } + + @Test fun `every endpoint is documented with a summary`() { + val undocumented = document().productionOperations() + .filter { (_, operation) -> operation.path("summary").asText("").isBlank() } + .map { (endpoint, _) -> endpoint } + + assertTrue(undocumented.isEmpty(), "endpoints missing an @Operation summary: $undocumented") + } + + @Test fun `every endpoint is grouped under a tag`() { + val untagged = document().productionOperations() + .filterNot { (_, operation) -> operation.path("tags").elements().hasNext() } + .map { (endpoint, _) -> endpoint } + + assertTrue(untagged.isEmpty(), "endpoints missing a @Tag: $untagged") + } + + @Test fun `all nine domains are present as tags`() { + val tags = document().productionOperations() + .flatMap { (_, operation) -> operation.path("tags").map { it.asText() } } + .toSet() + + assertEquals( + setOf("Auth", "Member", "Media", "Capsule", "Friend", "Notification", "Feed", "Block", "Report"), + tags, + ) + } + + @Test fun `every documented endpoint carries a description as well as a summary`() { + val missing = document().productionOperations() + .filter { (_, operation) -> operation.path("description").asText("").isBlank() } + .map { (endpoint, _) -> endpoint } + + assertTrue(missing.isEmpty(), "endpoints missing an @Operation description: $missing") + } + + @Test fun `the bearer security scheme is registered so Authorize works in the UI`() { + val scheme = document().path("components").path("securitySchemes").path("bearerAuth") + + assertEquals("http", scheme.path("type").asText()) + assertEquals("bearer", scheme.path("scheme").asText()) + assertEquals("JWT", scheme.path("bearerFormat").asText()) + } + + @Test fun `the api carries a title and version`() { + val info = document().path("info") + + assertEquals("MUDDA API", info.path("title").asText()) + assertEquals("v1", info.path("version").asText()) + } +} From 1bd0dafd54204a3bd63680946e33a2004d661c82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=95=98=EB=AF=BC?= Date: Sun, 30 Aug 2026 12:42:08 +0900 Subject: [PATCH 5/6] test: #30 :: hide the security test controller from the openapi document --- .../global/config/OpenApiDocumentTest.kt | 21 +++++++------------ .../mudda/global/config/SecurityConfigTest.kt | 5 +++++ 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/test/kotlin/team/cklob/mudda/global/config/OpenApiDocumentTest.kt b/src/test/kotlin/team/cklob/mudda/global/config/OpenApiDocumentTest.kt index 3a113c4..54b0474 100644 --- a/src/test/kotlin/team/cklob/mudda/global/config/OpenApiDocumentTest.kt +++ b/src/test/kotlin/team/cklob/mudda/global/config/OpenApiDocumentTest.kt @@ -23,22 +23,15 @@ class OpenApiDocumentTest( return objectMapper.readTree(body) } - // SecurityConfigTest declares a @RestController fixture that component scanning pulls into this - // context, so it appears in the generated document during tests but never in production. It is not - // part of the API under test. - private fun JsonNode.productionOperations(): List> = + // Test-only controllers are kept out of the document by @Hidden at their declaration, so everything + // reaching this point is a real endpoint. + private fun JsonNode.operations(): List> = path("paths").fields().asSequence().flatMap { (path, methods) -> methods.fields().asSequence().map { (method, operation) -> "$method $path" to operation } - }.filterNot { (_, operation) -> - operation.path("tags").any { it.asText() in TEST_ONLY_TAGS } }.toList() - private companion object { - val TEST_ONLY_TAGS = setOf("security-test-controller") - } - @Test fun `every endpoint is documented with a summary`() { - val undocumented = document().productionOperations() + val undocumented = document().operations() .filter { (_, operation) -> operation.path("summary").asText("").isBlank() } .map { (endpoint, _) -> endpoint } @@ -46,7 +39,7 @@ class OpenApiDocumentTest( } @Test fun `every endpoint is grouped under a tag`() { - val untagged = document().productionOperations() + val untagged = document().operations() .filterNot { (_, operation) -> operation.path("tags").elements().hasNext() } .map { (endpoint, _) -> endpoint } @@ -54,7 +47,7 @@ class OpenApiDocumentTest( } @Test fun `all nine domains are present as tags`() { - val tags = document().productionOperations() + val tags = document().operations() .flatMap { (_, operation) -> operation.path("tags").map { it.asText() } } .toSet() @@ -65,7 +58,7 @@ class OpenApiDocumentTest( } @Test fun `every documented endpoint carries a description as well as a summary`() { - val missing = document().productionOperations() + val missing = document().operations() .filter { (_, operation) -> operation.path("description").asText("").isBlank() } .map { (endpoint, _) -> endpoint } diff --git a/src/test/kotlin/team/cklob/mudda/global/config/SecurityConfigTest.kt b/src/test/kotlin/team/cklob/mudda/global/config/SecurityConfigTest.kt index 69f3ee2..a219fa4 100644 --- a/src/test/kotlin/team/cklob/mudda/global/config/SecurityConfigTest.kt +++ b/src/test/kotlin/team/cklob/mudda/global/config/SecurityConfigTest.kt @@ -1,5 +1,6 @@ package team.cklob.mudda.global.config +import io.swagger.v3.oas.annotations.Hidden import io.mockk.every import org.junit.jupiter.api.Test import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest @@ -32,6 +33,10 @@ class SecurityConfigTest(@Autowired private val mockMvc: MockMvc, @Autowired pri } } +// @Hidden keeps this fixture out of the generated OpenAPI document. Component scanning pulls it into +// any full @SpringBootTest context, where it would otherwise show up as a real endpoint alongside the +// actual API (see OpenApiDocumentTest). +@Hidden @RestController class SecurityTestController { @GetMapping("/api/v1/maps/ping") fun public() = "ok" From 09934dbe7418268b1b55a6ec78ca081a54aa647e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=95=98=EB=AF=BC?= Date: Sun, 30 Aug 2026 14:02:16 +0900 Subject: [PATCH 6/6] fix: #30 :: close concurrency races in block, report, and media cleanup --- .../block/application/impl/BlockServices.kt | 20 ++-- .../domain/repository/BlockRepository.kt | 17 ++++ .../domain/repository/MediaRepository.kt | 16 ++++ .../application/impl/CreateReportService.kt | 31 ++++-- .../application/impl/CreateCapsuleService.kt | 2 +- .../application/impl/BlockServicesTest.kt | 19 ++-- .../BlockConcurrencyIntegrationTest.kt | 59 ++++++++++++ .../MediaCleanupLockIntegrationTest.kt | 96 +++++++++++++++++++ .../impl/CreateReportServiceTest.kt | 19 +++- .../ReportConcurrencyIntegrationTest.kt | 59 ++++++++++++ 10 files changed, 307 insertions(+), 31 deletions(-) create mode 100644 src/test/kotlin/team/cklob/mudda/domain/block/domain/repository/BlockConcurrencyIntegrationTest.kt create mode 100644 src/test/kotlin/team/cklob/mudda/domain/media/domain/repository/MediaCleanupLockIntegrationTest.kt create mode 100644 src/test/kotlin/team/cklob/mudda/domain/report/domain/repository/ReportConcurrencyIntegrationTest.kt diff --git a/src/main/kotlin/team/cklob/mudda/domain/block/application/impl/BlockServices.kt b/src/main/kotlin/team/cklob/mudda/domain/block/application/impl/BlockServices.kt index acc7624..c22646b 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/block/application/impl/BlockServices.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/block/application/impl/BlockServices.kt @@ -3,7 +3,6 @@ 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.entity.Block 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 @@ -29,17 +28,16 @@ class CreateBlockService( 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. - val existing = blockRepository.findByBlockerIdAndBlockedId(memberId, targetId).orElse(null) - if (existing != null) { - return CreateBlockResponse(requireNotNull(existing.id), targetId, existing.createdAt) - } - - val blocker = memberRepository.findById(memberId).orElseThrow { BusinessException(ErrorCode.MEMBER_NOT_FOUND) } - val saved = blockRepository.save(Block(blocker = blocker, blocked = target)) - return CreateBlockResponse(requireNotNull(saved.id), targetId, saved.createdAt) + // 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) } } diff --git a/src/main/kotlin/team/cklob/mudda/domain/block/domain/repository/BlockRepository.kt b/src/main/kotlin/team/cklob/mudda/domain/block/domain/repository/BlockRepository.kt index 5fa59db..31b284d 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/block/domain/repository/BlockRepository.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/block/domain/repository/BlockRepository.kt @@ -3,6 +3,7 @@ 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 @@ -36,4 +37,20 @@ interface BlockRepository : JpaRepository { """, ) fun findBlockedMemberIds(@Param("memberId") memberId: Long, @Param("otherIds") otherIds: Collection): Set + + // 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 } diff --git a/src/main/kotlin/team/cklob/mudda/domain/media/domain/repository/MediaRepository.kt b/src/main/kotlin/team/cklob/mudda/domain/media/domain/repository/MediaRepository.kt index 362c2d8..6aca0a8 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/media/domain/repository/MediaRepository.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/media/domain/repository/MediaRepository.kt @@ -2,6 +2,8 @@ package team.cklob.mudda.domain.media.domain.repository import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.JpaRepository +import jakarta.persistence.LockModeType +import org.springframework.data.jpa.repository.Lock import org.springframework.data.jpa.repository.Modifying import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param @@ -17,9 +19,23 @@ interface MediaRepository : JpaRepository { // Media registered through the upload-complete flow but never attached to a capsule. V4 made // time_capsule_id nullable to allow that intermediate state, which means an abandoned compose leaves // both the row and its S3 object behind forever. + // + // Locked because the cleanup job deletes the S3 object after this read: without the lock a capsule + // creation could attach one of these rows in between, and the job would then destroy media a live + // capsule points at. Under PostgreSQL's read-committed isolation, FOR UPDATE re-evaluates the WHERE + // clause once the lock is granted, so a row attached by a transaction that committed while we waited + // drops out of the result instead of being returned as still-unattached. + @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT m FROM Media m WHERE m.timeCapsule IS NULL AND m.createdAt < :threshold") fun findUnattachedOlderThan(@Param("threshold") threshold: LocalDateTime, pageable: Pageable): List + // The attach side of the same lock: CreateCapsuleService takes it before pointing media at a capsule, + // so an in-flight cleanup finishes first and the attach then correctly fails validation on the + // now-deleted row rather than resurrecting it. + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT m FROM Media m WHERE m.id IN :ids") + fun findAllByIdForUpdate(@Param("ids") ids: Collection): List + @Modifying @Transactional @Query( diff --git a/src/main/kotlin/team/cklob/mudda/domain/report/application/impl/CreateReportService.kt b/src/main/kotlin/team/cklob/mudda/domain/report/application/impl/CreateReportService.kt index 2c3ccf4..6579e33 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/report/application/impl/CreateReportService.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/report/application/impl/CreateReportService.kt @@ -1,5 +1,7 @@ package team.cklob.mudda.domain.report.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.member.domain.repository.MemberRepository @@ -21,6 +23,8 @@ class CreateReportService( private val capsuleRepository: TimeCapsuleRepository, private val guestbookRepository: GuestbookRepository, ) { + private val logger = LoggerFactory.getLogger(javaClass) + @Transactional fun execute(memberId: Long, request: CreateReportRequest): CreateReportResponse { val targetType = requireNotNull(request.targetType) @@ -44,15 +48,24 @@ class CreateReportService( } val reporter = memberRepository.findById(memberId).orElseThrow { BusinessException(ErrorCode.MEMBER_NOT_FOUND) } - val saved = reportRepository.save( - Report( - reporter = reporter, - targetType = targetType, - targetId = targetId, - reason = reason, - description = request.description?.trim(), - ), - ) + val saved = try { + reportRepository.saveAndFlush( + Report( + reporter = reporter, + targetType = targetType, + targetId = targetId, + reason = reason, + description = request.description?.trim(), + ), + ) + } catch (e: DataIntegrityViolationException) { + // Two concurrent reports of the same target both pass the check above and collide on + // uq_report_reporter_target. The constraint is the real guarantee; this turns the loser into the + // same 409 the sequential path returns instead of a 500. Nothing needs to be persisted here, so + // letting the transaction roll back is the correct outcome. + logger.debug("concurrent duplicate report: reporter={}, target={}:{}", memberId, targetType, targetId, e) + throw BusinessException(ErrorCode.ALREADY_REPORTED) + } return CreateReportResponse.from(saved) } diff --git a/src/main/kotlin/team/cklob/mudda/domain/timecapsule/application/impl/CreateCapsuleService.kt b/src/main/kotlin/team/cklob/mudda/domain/timecapsule/application/impl/CreateCapsuleService.kt index 2ac1f21..d1aeeb8 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/timecapsule/application/impl/CreateCapsuleService.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/timecapsule/application/impl/CreateCapsuleService.kt @@ -49,7 +49,7 @@ class CreateCapsuleService( if (recipients.size != request.recipientIds.size || request.recipientIds.any { !validRecipient(memberId, it) }) { throw BusinessException(ErrorCode.INVALID_CAPSULE_RECIPIENT) } - val media = mediaRepository.findAllById(request.mediaIds) + val media = mediaRepository.findAllByIdForUpdate(request.mediaIds) if (media.size != request.mediaIds.size || media.any { it.uploader.id != memberId || it.timeCapsule != null }) { throw BusinessException(ErrorCode.INVALID_CAPSULE_MEDIA) } diff --git a/src/test/kotlin/team/cklob/mudda/domain/block/application/impl/BlockServicesTest.kt b/src/test/kotlin/team/cklob/mudda/domain/block/application/impl/BlockServicesTest.kt index 0632ce9..4cfbe5b 100644 --- a/src/test/kotlin/team/cklob/mudda/domain/block/application/impl/BlockServicesTest.kt +++ b/src/test/kotlin/team/cklob/mudda/domain/block/application/impl/BlockServicesTest.kt @@ -43,30 +43,33 @@ class BlockServicesTest { assertEquals(ErrorCode.MEMBER_NOT_FOUND, error.errorCode) } - // Blocking twice lands on the same end state, so it returns the existing row rather than a conflict - // the client would have to special-case. + // Blocking twice lands on the same end state, so the existing row is returned rather than a conflict + // the client would have to special-case. insertIfAbsent reports 0 rows affected for the repeat, which + // is also the path a concurrent loser takes. @Test fun `blocking an already blocked member is idempotent`() { val existing = Block(blocker = member(1), blocked = member(2), id = 9) every { memberRepository.findById(2) } returns Optional.of(member(2)) + every { memberRepository.existsById(1) } returns true + every { blockRepository.insertIfAbsent(1, 2) } returns 0 every { blockRepository.findByBlockerIdAndBlockedId(1, 2) } returns Optional.of(existing) val response = createService.execute(1, CreateBlockRequest(2)) assertEquals(9, response.blockId) - verify(exactly = 0) { blockRepository.save(any()) } } // The whole block policy rests on read-path filtering: nothing is deleted, so unblocking restores the // prior friendship and pending requests for free. @Test fun `blocking writes one row and deletes nothing`() { every { memberRepository.findById(2) } returns Optional.of(member(2)) - every { memberRepository.findById(1) } returns Optional.of(member(1)) - every { blockRepository.findByBlockerIdAndBlockedId(1, 2) } returns Optional.empty() - every { blockRepository.save(any()) } answers { Block(member(1), member(2), id = 5) } + every { memberRepository.existsById(1) } returns true + every { blockRepository.insertIfAbsent(1, 2) } returns 1 + every { blockRepository.findByBlockerIdAndBlockedId(1, 2) } returns Optional.of(Block(member(1), member(2), id = 5)) - createService.execute(1, CreateBlockRequest(2)) + val response = createService.execute(1, CreateBlockRequest(2)) - verify(exactly = 1) { blockRepository.save(any()) } + assertEquals(5, response.blockId) + verify(exactly = 1) { blockRepository.insertIfAbsent(1, 2) } verify(exactly = 0) { blockRepository.delete(any()) } } diff --git a/src/test/kotlin/team/cklob/mudda/domain/block/domain/repository/BlockConcurrencyIntegrationTest.kt b/src/test/kotlin/team/cklob/mudda/domain/block/domain/repository/BlockConcurrencyIntegrationTest.kt new file mode 100644 index 0000000..fcf6065 --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/domain/block/domain/repository/BlockConcurrencyIntegrationTest.kt @@ -0,0 +1,59 @@ +package team.cklob.mudda.domain.block.domain.repository + +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import team.cklob.mudda.domain.member.domain.entity.Member +import team.cklob.mudda.domain.member.domain.repository.MemberRepository +import team.cklob.mudda.domain.member.domain.type.OAuthProvider +import team.cklob.mudda.domain.member.domain.type.ProfileVisibility +import team.cklob.mudda.support.PostgresIntegrationTest +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +// The idempotent block contract rests on an atomic insert rather than a read-then-write check, so it is +// verified against the real uq_block_blocker_blocked constraint. +class BlockConcurrencyIntegrationTest( + @Autowired private val blockRepository: BlockRepository, + @Autowired private val memberRepository: MemberRepository, +) : PostgresIntegrationTest() { + private fun member(tag: String) = memberRepository.saveAndFlush( + Member( + name = "name", nickname = "nick-$tag", email = "block-$tag@example.com", + oauthProvider = OAuthProvider.GOOGLE, providerId = "block-provider-$tag", + profileVisibility = ProfileVisibility.PUBLIC, + ), + ) + + @Test fun `the first insert creates a row and reports one affected`() { + val blocker = member("a") + val blocked = member("b") + + val affected = blockRepository.insertIfAbsent(requireNotNull(blocker.id), requireNotNull(blocked.id)) + + assertEquals(1, affected) + assertTrue(blockRepository.findByBlockerIdAndBlockedId(requireNotNull(blocker.id), requireNotNull(blocked.id)).isPresent) + } + + // This is the path a concurrent loser takes: the unique constraint would otherwise surface as a + // DataIntegrityViolationException and a 500. + @Test fun `a repeated insert affects nothing instead of violating the unique constraint`() { + val blocker = member("c") + val blocked = member("d") + blockRepository.insertIfAbsent(requireNotNull(blocker.id), requireNotNull(blocked.id)) + + val affected = blockRepository.insertIfAbsent(requireNotNull(blocker.id), requireNotNull(blocked.id)) + + assertEquals(0, affected) + assertEquals(1, blockRepository.findByBlockerId(requireNotNull(blocker.id)).size) + } + + @Test fun `blocking in the opposite direction is a separate row`() { + val a = member("e") + val b = member("f") + blockRepository.insertIfAbsent(requireNotNull(a.id), requireNotNull(b.id)) + + val affected = blockRepository.insertIfAbsent(requireNotNull(b.id), requireNotNull(a.id)) + + assertEquals(1, affected) + } +} diff --git a/src/test/kotlin/team/cklob/mudda/domain/media/domain/repository/MediaCleanupLockIntegrationTest.kt b/src/test/kotlin/team/cklob/mudda/domain/media/domain/repository/MediaCleanupLockIntegrationTest.kt new file mode 100644 index 0000000..1be0588 --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/domain/media/domain/repository/MediaCleanupLockIntegrationTest.kt @@ -0,0 +1,96 @@ +package team.cklob.mudda.domain.media.domain.repository + +import org.junit.jupiter.api.Test +import org.locationtech.jts.geom.Coordinate +import org.locationtech.jts.geom.GeometryFactory +import org.locationtech.jts.geom.PrecisionModel +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.data.domain.PageRequest +import team.cklob.mudda.domain.media.domain.entity.Media +import team.cklob.mudda.domain.media.domain.type.MediaType +import team.cklob.mudda.domain.member.domain.entity.Member +import team.cklob.mudda.domain.member.domain.repository.MemberRepository +import team.cklob.mudda.domain.member.domain.type.OAuthProvider +import team.cklob.mudda.domain.member.domain.type.ProfileVisibility +import team.cklob.mudda.domain.timecapsule.domain.entity.TimeCapsule +import team.cklob.mudda.domain.timecapsule.domain.repository.TimeCapsuleRepository +import team.cklob.mudda.domain.timecapsule.domain.type.CapsuleLockType +import team.cklob.mudda.domain.timecapsule.domain.type.CapsuleVisibility +import team.cklob.mudda.support.PostgresIntegrationTest +import java.time.LocalDateTime +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +// Both sides of the cleanup/attach race take a pessimistic lock, so these queries are exercised against +// real PostgreSQL: a malformed FOR UPDATE (or one combined illegally with the page limit) only fails at +// runtime, never at compile time. +class MediaCleanupLockIntegrationTest( + @Autowired private val mediaRepository: MediaRepository, + @Autowired private val memberRepository: MemberRepository, + @Autowired private val capsuleRepository: TimeCapsuleRepository, +) : PostgresIntegrationTest() { + private val old = LocalDateTime.now().minusDays(7) + + private fun member(tag: String) = memberRepository.saveAndFlush( + Member( + name = "name", nickname = "nick-$tag", email = "media-$tag@example.com", + oauthProvider = OAuthProvider.GOOGLE, providerId = "media-provider-$tag", + profileVisibility = ProfileVisibility.PUBLIC, + ), + ) + + private fun capsule(owner: Member) = capsuleRepository.saveAndFlush( + TimeCapsule( + member = owner, name = "capsule", visibility = CapsuleVisibility.PUBLIC, lockType = CapsuleLockType.NONE, + location = GeometryFactory(PrecisionModel(), 4326).createPoint(Coordinate(127.0, 37.5)), + openRadiusMeter = 100, openAt = LocalDateTime.now(), + ), + ) + + private fun media(uploader: Member, key: String, capsule: TimeCapsule? = null) = + mediaRepository.saveAndFlush(Media(uploader, capsule, MediaType.IMAGE, key)) + + @Test fun `the locking cleanup query returns only unattached rows`() { + val uploader = member("a") + val orphan = media(uploader, "orphan-a") + media(uploader, "attached-a", capsule(uploader)) + + val found = mediaRepository.findUnattachedOlderThan(LocalDateTime.now().plusMinutes(1), PageRequest.of(0, 500)) + + val ids = found.map { it.id } + assertTrue(orphan.id in ids) + assertTrue(found.all { it.timeCapsule == null }, "an attached row must never be a cleanup candidate") + } + + @Test fun `the cleanup query respects the batch limit while locking`() { + val uploader = member("b") + repeat(3) { media(uploader, "batch-b-$it") } + + val found = mediaRepository.findUnattachedOlderThan(LocalDateTime.now().plusMinutes(1), PageRequest.of(0, 2)) + + assertEquals(2, found.size) + } + + @Test fun `the cleanup query ignores rows newer than the threshold`() { + val uploader = member("c") + media(uploader, "fresh-c") + + val found = mediaRepository.findUnattachedOlderThan(old, PageRequest.of(0, 500)) + + assertTrue(found.none { it.s3Key == "fresh-c" }) + } + + @Test fun `the attach-side locking load returns the requested rows`() { + val uploader = member("d") + val first = media(uploader, "attach-d-1") + val second = media(uploader, "attach-d-2") + + val found = mediaRepository.findAllByIdForUpdate(listOf(requireNotNull(first.id), requireNotNull(second.id))) + + assertEquals(2, found.size) + } + + @Test fun `the attach-side locking load tolerates an empty id set`() { + assertTrue(mediaRepository.findAllByIdForUpdate(emptyList()).isEmpty()) + } +} diff --git a/src/test/kotlin/team/cklob/mudda/domain/report/application/impl/CreateReportServiceTest.kt b/src/test/kotlin/team/cklob/mudda/domain/report/application/impl/CreateReportServiceTest.kt index d7d1405..469ebe9 100644 --- a/src/test/kotlin/team/cklob/mudda/domain/report/application/impl/CreateReportServiceTest.kt +++ b/src/test/kotlin/team/cklob/mudda/domain/report/application/impl/CreateReportServiceTest.kt @@ -5,6 +5,7 @@ import io.mockk.mockk import io.mockk.verify import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows +import org.springframework.dao.DataIntegrityViolationException import org.locationtech.jts.geom.Coordinate import org.locationtech.jts.geom.GeometryFactory import team.cklob.mudda.domain.member.domain.entity.Member @@ -79,14 +80,14 @@ class CreateReportServiceTest { val error = assertThrows { service.execute(1, request) } assertEquals(ErrorCode.ALREADY_REPORTED, error.errorCode) - verify(exactly = 0) { reportRepository.save(any()) } + verify(exactly = 0) { reportRepository.saveAndFlush(any()) } } @Test fun `a valid capsule report is stored`() { every { capsuleRepository.findById(5) } returns Optional.of(capsule()) every { reportRepository.existsByReporterIdAndTargetTypeAndTargetId(1, ReportTargetType.CAPSULE, 5) } returns false every { memberRepository.findById(1) } returns Optional.of(member(1)) - every { reportRepository.save(any()) } answers { + every { reportRepository.saveAndFlush(any()) } answers { Report(member(1), ReportTargetType.CAPSULE, 5, ReportReason.ABUSE, "욕설", id = 7) } val request = CreateReportRequest(ReportTargetType.CAPSULE, 5, ReportReason.ABUSE, "욕설") @@ -96,4 +97,18 @@ class CreateReportServiceTest { assertEquals(7, response.reportId) assertEquals(ReportReason.ABUSE, response.reason) } + + // A concurrent duplicate slips past the exists check and is stopped by uq_report_reporter_target. The + // violation must surface as the same 409 the sequential path returns, not a 500. + @Test fun `a unique violation from a concurrent duplicate becomes ALREADY_REPORTED`() { + every { capsuleRepository.findById(5) } returns Optional.of(capsule()) + every { reportRepository.existsByReporterIdAndTargetTypeAndTargetId(1, ReportTargetType.CAPSULE, 5) } returns false + every { memberRepository.findById(1) } returns Optional.of(member(1)) + every { reportRepository.saveAndFlush(any()) } throws DataIntegrityViolationException("uq_report_reporter_target") + val request = CreateReportRequest(ReportTargetType.CAPSULE, 5, ReportReason.ABUSE) + + val error = assertThrows { service.execute(1, request) } + + assertEquals(ErrorCode.ALREADY_REPORTED, error.errorCode) + } } diff --git a/src/test/kotlin/team/cklob/mudda/domain/report/domain/repository/ReportConcurrencyIntegrationTest.kt b/src/test/kotlin/team/cklob/mudda/domain/report/domain/repository/ReportConcurrencyIntegrationTest.kt new file mode 100644 index 0000000..e138c4b --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/domain/report/domain/repository/ReportConcurrencyIntegrationTest.kt @@ -0,0 +1,59 @@ +package team.cklob.mudda.domain.report.domain.repository + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.dao.DataIntegrityViolationException +import team.cklob.mudda.domain.member.domain.entity.Member +import team.cklob.mudda.domain.member.domain.repository.MemberRepository +import team.cklob.mudda.domain.member.domain.type.OAuthProvider +import team.cklob.mudda.domain.member.domain.type.ProfileVisibility +import team.cklob.mudda.domain.report.domain.entity.Report +import team.cklob.mudda.domain.report.domain.type.ReportReason +import team.cklob.mudda.domain.report.domain.type.ReportTargetType +import team.cklob.mudda.support.PostgresIntegrationTest +import kotlin.test.assertEquals + +// CreateReportService relies on uq_report_reporter_target to be the real duplicate guarantee and maps the +// resulting violation to 409, so the constraint's actual behaviour is pinned here. +class ReportConcurrencyIntegrationTest( + @Autowired private val reportRepository: ReportRepository, + @Autowired private val memberRepository: MemberRepository, +) : PostgresIntegrationTest() { + private fun member(tag: String) = memberRepository.saveAndFlush( + Member( + name = "name", nickname = "nick-$tag", email = "report-$tag@example.com", + oauthProvider = OAuthProvider.GOOGLE, providerId = "report-provider-$tag", + profileVisibility = ProfileVisibility.PUBLIC, + ), + ) + + private fun report(reporter: Member, targetId: Long, targetType: ReportTargetType = ReportTargetType.CAPSULE) = + reportRepository.saveAndFlush(Report(reporter, targetType, targetId, ReportReason.ABUSE)) + + @Test fun `reporting the same target twice violates the unique constraint`() { + val reporter = member("a") + report(reporter, 100) + + assertThrows { report(reporter, 100) } + } + + @Test fun `the constraint is scoped by target type so the same id in another type is allowed`() { + val reporter = member("b") + report(reporter, 200, ReportTargetType.CAPSULE) + + report(reporter, 200, ReportTargetType.GUESTBOOK) + + assertEquals(2, reportRepository.count()) + } + + @Test fun `enum values round-trip through the varchar columns`() { + val reporter = member("c") + val saved = report(reporter, 300) + + val found = reportRepository.findById(requireNotNull(saved.id)).orElseThrow() + + assertEquals(ReportTargetType.CAPSULE, found.targetType) + assertEquals(ReportReason.ABUSE, found.reason) + } +}