diff --git a/backend/election/vote/src/main/kotlin/com/futiland/vote/application/config/security/SecurityConfig.kt b/backend/election/vote/src/main/kotlin/com/futiland/vote/application/config/security/SecurityConfig.kt index 417bf05..58bb59c 100644 --- a/backend/election/vote/src/main/kotlin/com/futiland/vote/application/config/security/SecurityConfig.kt +++ b/backend/election/vote/src/main/kotlin/com/futiland/vote/application/config/security/SecurityConfig.kt @@ -95,6 +95,17 @@ class SecurityConfig( // Poll - 내 여론조사 조회는 인증 필요 (더 구체적인 규칙을 먼저 선언) httpRequest.requestMatchers("GET", "/poll/v1/my").authenticated() + // Poll - 내가 참여한 투표 목록은 인증 필요 + httpRequest.requestMatchers("GET", "/poll/v1/public/response/my").authenticated() + httpRequest.requestMatchers("GET", "/poll/v1/system/response/my").authenticated() + + // Poll Response - 비로그인 투표 허용 (서비스에서 검증) + httpRequest.requestMatchers("POST", "/poll/v1/*/response").permitAll() + httpRequest.requestMatchers("PUT", "/poll/v1/*/response").permitAll() + httpRequest.requestMatchers("DELETE", "/poll/v1/*/response").permitAll() + + // Poll Result 조회 - 비로그인도 허용 (서비스에서 참여 여부 검증) + httpRequest.requestMatchers("GET", "/poll/v1/*/result").permitAll() // Poll 조회 - GET만 인증 불필요 httpRequest.requestMatchers("GET", "/poll/v1/**").permitAll() diff --git a/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/controller/PollCommandController.kt b/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/controller/PollCommandController.kt index 1b47519..a6574f5 100644 --- a/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/controller/PollCommandController.kt +++ b/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/controller/PollCommandController.kt @@ -35,6 +35,10 @@ class PollCommandController( - MULTIPLE_CHOICE: 다중 선택 (options 필수, minSelections/maxSelections 설정 가능) - SCORE: 점수제 (options 불필요, minScore/maxScore 설정) + **비로그인 투표 (allowAnonymousVote):** + - true: 비로그인 사용자도 anonymous_session 쿠키로 투표 가능 + - false (기본): 로그인한 사용자만 투표 가능 + **주의사항:** - startAt과 endAt은 필수입니다 - 점수제가 아닌 경우 최소 2개 이상의 옵션이 필요합니다 @@ -88,6 +92,10 @@ class PollCommandController( - MULTIPLE_CHOICE: 다중 선택 (options 필수, minSelections/maxSelections 설정 가능) - SCORE: 점수제 (options 불필요, minScore/maxScore 설정) + **비로그인 투표 (allowAnonymousVote):** + - true: 비로그인 사용자도 anonymous_session 쿠키로 투표 가능 + - false (기본): 로그인한 사용자만 투표 가능 + **주의사항:** - DRAFT 상태에서는 응답을 받을 수 없습니다 - 나중에 수정하여 시작/종료 시간을 설정하고 IN_PROGRESS 상태로 전환할 수 있습니다 @@ -145,6 +153,7 @@ class PollCommandController( - endAt은 필수입니다 - 점수제가 아닌 경우 최소 2개 이상의 옵션이 필요합니다 - 시스템 여론조사는 공개 목록에 노출되지 않으며 별도 API로 조회합니다 + - 비로그인 투표(allowAnonymousVote)는 지원하지 않습니다 (항상 로그인 필수) """ ) @ApiResponses( diff --git a/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/controller/PollQueryController.kt b/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/controller/PollQueryController.kt index 982b9b0..1c32d06 100644 --- a/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/controller/PollQueryController.kt +++ b/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/controller/PollQueryController.kt @@ -63,8 +63,13 @@ class PollQueryController( - EXPIRED: 기간만료만 **투표 여부 (isVoted):** - - 로그인한 경우: 각 여론조사에 투표했는지 여부 (true/false) - - 비로그인: 항상 false + - 로그인한 경우: accountId 기반으로 투표 여부 판단 + - 비로그인 + anonymous_session 쿠키: 쿠키 기반으로 투표 여부 판단 + - 비로그인 + 쿠키 없음: 항상 false + + **비로그인 투표 (allowAnonymousVote):** + - allowAnonymousVote=true인 여론조사는 비로그인 사용자도 투표 가능 + - anonymous_session 쿠키가 있으면 해당 쿠키로 투표 여부를 판단합니다 """ ) @ApiResponses( @@ -108,8 +113,15 @@ class PollQueryController( @RequestParam(defaultValue = "ALL") status: PollStatusFilter = PollStatusFilter.ALL, @Parameter(hidden = true) @AuthenticationPrincipal userDetails: CustomUserDetails?, + @Parameter( + description = "비로그인 사용자 식별용 쿠키 (서버가 Set-Cookie로 발급, 브라우저가 자동 전송. 투표 여부 판단에 사용)", + example = "550e8400-e29b-41d4-a716-446655440000", + required = false + ) + @CookieValue("anonymous_session", required = false) pollSession: String?, ): HttpApiResponse> { val accountId = userDetails?.user?.accountId + val anonymousSessionId = if (accountId == null) pollSession else null val response = pollQueryFacadeUseCase.getPollListByType( pollType = type, accountId = accountId, @@ -117,7 +129,8 @@ class PollQueryController( nextCursor = nextCursor, keyword = keyword ?: "", sortType = sort, - statusFilter = status + statusFilter = status, + anonymousSessionId = anonymousSessionId ) return HttpApiResponse.of(response) } @@ -133,6 +146,8 @@ class PollQueryController( - 상태 정보 (DRAFT, IN_PROGRESS, EXPIRED, CANCELLED 등) - 기간 정보 (시작/종료 일시) - 생성자 정보 + - allowAnonymousVote: 비로그인 투표 허용 여부 + - isVoted: 현재 사용자의 투표 여부 (비로그인 시 anonymous_session 쿠키로 판단) """ ) @ApiResponses( @@ -155,9 +170,16 @@ class PollQueryController( @PathVariable pollId: Long, @Parameter(hidden = true) @AuthenticationPrincipal userDetails: CustomUserDetails?, + @Parameter( + description = "비로그인 사용자 식별용 쿠키 (서버가 Set-Cookie로 발급, 브라우저가 자동 전송. 투표 여부 판단에 사용)", + example = "550e8400-e29b-41d4-a716-446655440000", + required = false + ) + @CookieValue("anonymous_session", required = false) pollSession: String?, ): HttpApiResponse { val accountId = userDetails?.user?.accountId - val response = pollQueryFacadeUseCase.getPollDetail(pollId, accountId) + val anonymousSessionId = if (accountId == null) pollSession else null + val response = pollQueryFacadeUseCase.getPollDetail(pollId, accountId, anonymousSessionId) return HttpApiResponse.of(response) } diff --git a/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/controller/PollResponseCommandController.kt b/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/controller/PollResponseCommandController.kt index 473221f..0d4f0e1 100644 --- a/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/controller/PollResponseCommandController.kt +++ b/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/controller/PollResponseCommandController.kt @@ -11,14 +11,19 @@ import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.servlet.http.Cookie +import jakarta.servlet.http.HttpServletResponse +import org.springframework.core.env.Environment import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.* +import java.util.UUID @Tag(name = "여론조사 응답", description = "여론조사 응답 제출, 수정, 삭제 API") @RestController @RequestMapping("/poll/v1") class PollResponseCommandController( private val pollResponseCommandUseCase: PollResponseCommandUseCase, + private val environment: Environment, ) { @Operation( summary = "여론조사 응답 제출", @@ -30,11 +35,19 @@ class PollResponseCommandController( - MULTIPLE_CHOICE: {"responseType": "MULTIPLE_CHOICE", "optionIds": [1, 2, 3]} - SCORE: {"responseType": "SCORE", "scoreValue": 8} + **비로그인 투표 (anonymous_session 쿠키):** + - 여론조사의 allowAnonymousVote=true인 경우, 로그인 없이도 투표 가능 + - 비로그인 사용자는 서버가 발급하는 anonymous_session 쿠키로 식별됩니다 + - 최초 투표 시 쿠키가 없으면 서버가 자동 발급 (Set-Cookie 응답 헤더) + - 이후 요청 시 브라우저가 쿠키를 자동 전송하여 동일 사용자 식별 + - 쿠키 속성: HttpOnly, Secure, Max-Age=1년 + - 민심투표(SYSTEM)는 비로그인 투표 불가 (항상 로그인 필수) + **주의사항:** - responseType은 여론조사의 responseType과 일치해야 합니다 - 이미 응답한 경우 에러가 발생합니다 (updateResponse 사용) - 투표 기간(startAt ~ endAt)에만 응답 가능합니다 - - 인증된 사용자만 응답 가능합니다 + - isRevotable 설정은 로그인/비로그인 동일하게 적용됩니다 """ ) @ApiResponses( @@ -51,7 +64,7 @@ class PollResponseCommandController( ), ApiResponse( responseCode = "401", - description = "인증 실패", + description = "인증 실패 (비로그인 투표가 허용되지 않는 경우)", content = [Content(schema = Schema(implementation = HttpApiResponse::class))] ), ApiResponse( @@ -72,11 +85,31 @@ class PollResponseCommandController( ) @RequestBody request: PollResponseSubmitRequest, @Parameter(hidden = true) - @AuthenticationPrincipal userDetails: CustomUserDetails, + @AuthenticationPrincipal userDetails: CustomUserDetails?, + @Parameter( + description = "비로그인 사용자 식별용 쿠키 (최초 투표 시 서버가 Set-Cookie로 발급, 이후 브라우저가 자동 전송)", + example = "550e8400-e29b-41d4-a716-446655440000", + required = false + ) + @CookieValue("anonymous_session", required = false) pollSession: String?, + @Parameter(hidden = true) + response: HttpServletResponse, ): HttpApiResponse { + val accountId = userDetails?.user?.accountId + + // 비로그인 사용자: 쿠키 없으면 새로 발급 + val anonymousSessionId = if (accountId == null) { + val sessionId = pollSession ?: UUID.randomUUID().toString() + if (pollSession == null) { + addPollSessionCookie(response, sessionId) + } + sessionId + } else null + val responseId = pollResponseCommandUseCase.submitResponse( pollId = pollId, - accountId = userDetails.user.accountId, + accountId = accountId, + anonymousSessionId = anonymousSessionId, request = request ) return HttpApiResponse.of(responseId) @@ -92,11 +125,15 @@ class PollResponseCommandController( - MULTIPLE_CHOICE: {"responseType": "MULTIPLE_CHOICE", "optionIds": [1, 2, 3]} - SCORE: {"responseType": "SCORE", "scoreValue": 8} + **비로그인 사용자:** + - anonymous_session 쿠키로 기존 응답을 식별하여 수정합니다 + - 쿠키가 없는 경우 서버가 새로 발급합니다 + **주의사항:** - responseType은 여론조사의 responseType과 일치해야 합니다 - 응답하지 않은 경우 에러가 발생합니다 (submitResponse 사용) - 투표 기간(startAt ~ endAt)에만 수정 가능합니다 - - 재투표가 허용된 여론조사만 수정 가능합니다 + - 재투표가 허용된(isRevotable=true) 여론조사만 수정 가능합니다 """ ) @ApiResponses( @@ -134,11 +171,30 @@ class PollResponseCommandController( ) @RequestBody request: PollResponseSubmitRequest, @Parameter(hidden = true) - @AuthenticationPrincipal userDetails: CustomUserDetails, + @AuthenticationPrincipal userDetails: CustomUserDetails?, + @Parameter( + description = "비로그인 사용자 식별용 쿠키 (최초 투표 시 서버가 Set-Cookie로 발급, 이후 브라우저가 자동 전송)", + example = "550e8400-e29b-41d4-a716-446655440000", + required = false + ) + @CookieValue("anonymous_session", required = false) pollSession: String?, + @Parameter(hidden = true) + response: HttpServletResponse, ): HttpApiResponse { + val accountId = userDetails?.user?.accountId + + val anonymousSessionId = if (accountId == null) { + val sessionId = pollSession ?: UUID.randomUUID().toString() + if (pollSession == null) { + addPollSessionCookie(response, sessionId) + } + sessionId + } else null + val responseId = pollResponseCommandUseCase.updateResponse( pollId = pollId, - accountId = userDetails.user.accountId, + accountId = accountId, + anonymousSessionId = anonymousSessionId, request = request ) return HttpApiResponse.of(responseId) @@ -149,10 +205,13 @@ class PollResponseCommandController( description = """ 이미 제출한 여론조사 응답을 삭제합니다. + **비로그인 사용자:** + - anonymous_session 쿠키로 기존 응답을 식별하여 삭제합니다 + **주의사항:** - 응답하지 않은 경우 에러가 발생합니다 - 투표 기간(startAt ~ endAt)에만 삭제 가능합니다 - - 재투표가 허용된 여론조사만 삭제 가능합니다 + - 재투표가 허용된(isRevotable=true) 여론조사만 삭제 가능합니다 - 삭제 후 다시 응답하려면 submitResponse를 사용하세요 """ ) @@ -185,12 +244,33 @@ class PollResponseCommandController( @Parameter(description = "여론조사 ID", required = true) @PathVariable pollId: Long, @Parameter(hidden = true) - @AuthenticationPrincipal userDetails: CustomUserDetails, + @AuthenticationPrincipal userDetails: CustomUserDetails?, + @Parameter( + description = "비로그인 사용자 식별용 쿠키 (최초 투표 시 서버가 Set-Cookie로 발급, 이후 브라우저가 자동 전송)", + example = "550e8400-e29b-41d4-a716-446655440000", + required = false + ) + @CookieValue("anonymous_session", required = false) pollSession: String?, ): HttpApiResponse { + val accountId = userDetails?.user?.accountId + val anonymousSessionId = if (accountId == null) pollSession else null + pollResponseCommandUseCase.deleteResponse( pollId = pollId, - accountId = userDetails.user.accountId + accountId = accountId, + anonymousSessionId = anonymousSessionId ) return HttpApiResponse.of(Unit) } + + private fun addPollSessionCookie(response: HttpServletResponse, sessionId: String) { + val isProduction = environment.activeProfiles.contains("prod") + val cookie = Cookie("anonymous_session", sessionId).apply { + path = "/" + isHttpOnly = true + maxAge = 365 * 24 * 60 * 60 // 1년 + secure = isProduction + } + response.addCookie(cookie) + } } diff --git a/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/controller/PollResponseQueryController.kt b/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/controller/PollResponseQueryController.kt index b3a23bf..9c3db8d 100644 --- a/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/controller/PollResponseQueryController.kt +++ b/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/controller/PollResponseQueryController.kt @@ -41,6 +41,10 @@ class PollResponseQueryController( - SCORE: scoreValue - 공통: createdAt, updatedAt + **비로그인 사용자:** + - anonymous_session 쿠키가 있으면 해당 쿠키로 myResponse를 조회합니다 + - 비로그인 투표 후 같은 브라우저에서 결과 조회 시 내 응답 정보 확인 가능 + **주의사항:** - 투표에 참여한 사용자만 결과를 조회할 수 있습니다 - 진행 중이거나 종료된 여론조사의 결과를 확인할 수 있습니다 @@ -94,9 +98,17 @@ class PollResponseQueryController( @Parameter(description = "여론조사 ID", required = true) @PathVariable pollId: Long, @Parameter(hidden = true) - @AuthenticationPrincipal userDetails: CustomUserDetails, + @AuthenticationPrincipal userDetails: CustomUserDetails?, + @Parameter( + description = "비로그인 사용자 식별용 쿠키 (서버가 Set-Cookie로 발급, 브라우저가 자동 전송. 내 응답 조회에 사용)", + example = "550e8400-e29b-41d4-a716-446655440000", + required = false + ) + @CookieValue("anonymous_session", required = false) pollSession: String?, ): HttpApiResponse { - val response = pollResultQueryUseCase.getPollResult(pollId, userDetails.user.accountId) + val accountId = userDetails?.user?.accountId + val anonymousSessionId = if (accountId == null) pollSession else null + val response = pollResultQueryUseCase.getPollResult(pollId, accountId, anonymousSessionId) return HttpApiResponse.of(response) } diff --git a/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/dto/request/PollCreateRequest.kt b/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/dto/request/PollCreateRequest.kt index c21dc87..70f0ed4 100644 --- a/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/dto/request/PollCreateRequest.kt +++ b/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/dto/request/PollCreateRequest.kt @@ -41,6 +41,9 @@ data class PublicPollCreateRequest( @Schema(description = "여론조사 종료 일시", example = "2025-01-17T23:59:59", required = true) val endAt: LocalDateTime, + @Schema(description = "비로그인 투표 허용 여부 (기본: false)", example = "false", required = false) + val allowAnonymousVote: Boolean = false, + @field:Valid @Schema(description = "선택지 목록 (SINGLE_CHOICE, MULTIPLE_CHOICE일 때 필수)", required = false) val options: List? = null, @@ -75,6 +78,9 @@ data class PublicPollDraftCreateRequest( @Schema(description = "재투표 가능 여부", example = "false", required = true) val isRevotable: Boolean, + @Schema(description = "비로그인 투표 허용 여부 (기본: false)", example = "false", required = false) + val allowAnonymousVote: Boolean = false, + @field:Valid @Schema(description = "선택지 목록 (SINGLE_CHOICE, MULTIPLE_CHOICE일 때 필수)", required = false) val options: List? = null, diff --git a/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/dto/response/PollDetailResponse.kt b/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/dto/response/PollDetailResponse.kt index 67a3afd..67f6f8b 100644 --- a/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/dto/response/PollDetailResponse.kt +++ b/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/dto/response/PollDetailResponse.kt @@ -1,21 +1,38 @@ package com.futiland.vote.application.poll.dto.response import com.futiland.vote.domain.poll.entity.* +import io.swagger.v3.oas.annotations.media.Schema import java.time.LocalDateTime +@Schema(description = "여론조사 상세 응답") data class PollDetailResponse( + @Schema(description = "여론조사 ID", example = "1") val id: Long, + @Schema(description = "여론조사 제목", example = "좋아하는 과일은?") val title: String, + @Schema(description = "여론조사 설명", example = "가장 좋아하는 과일을 선택하세요") val description: String, + @Schema(description = "응답 유형", example = "SINGLE_CHOICE") val responseType: ResponseType, + @Schema(description = "여론조사 상태", example = "IN_PROGRESS") val status: PollStatus, + @Schema(description = "재투표 가능 여부", example = "true") val isRevotable: Boolean, + @Schema(description = "비로그인 투표 허용 여부 (true: 비로그인 사용자도 투표 가능)", example = "false") + val allowAnonymousVote: Boolean, + @Schema(description = "시작 일시", example = "2024-01-01T00:00:00") val startAt: LocalDateTime?, + @Schema(description = "종료 일시", example = "2024-12-31T23:59:59") val endAt: LocalDateTime?, + @Schema(description = "생성 일시", example = "2024-01-01T00:00:00") val createdAt: LocalDateTime, + @Schema(description = "전체 응답 수 (로그인 + 비로그인 합산)", example = "1234") val responseCount: Long, + @Schema(description = "선택지 목록") val options: List, + @Schema(description = "현재 사용자의 투표 여부 (로그인: accountId 기반, 비로그인: anonymous_session 쿠키 기반)", example = "true") val isVoted: Boolean, + @Schema(description = "작성자 정보") val creatorInfo: CreatorInfoResponse, ) { companion object { @@ -33,6 +50,7 @@ data class PollDetailResponse( responseType = poll.responseType, status = poll.status, isRevotable = poll.isRevotable, + allowAnonymousVote = poll.allowAnonymousVote, startAt = poll.startAt, endAt = poll.endAt, createdAt = poll.createdAt, diff --git a/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/dto/response/PollListResponse.kt b/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/dto/response/PollListResponse.kt index 73338ee..9d20648 100644 --- a/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/dto/response/PollListResponse.kt +++ b/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/dto/response/PollListResponse.kt @@ -20,17 +20,19 @@ data class PollListResponse( val status: PollStatus, @Schema(description = "재투표 가능 여부", example = "true") val isRevotable: Boolean, + @Schema(description = "비로그인 투표 허용 여부", example = "false") + val allowAnonymousVote: Boolean, @Schema(description = "시작 일시", example = "2024-01-01T00:00:00") val startAt: LocalDateTime?, @Schema(description = "종료 일시", example = "2024-12-31T23:59:59") val endAt: LocalDateTime?, @Schema(description = "생성 일시", example = "2024-01-01T00:00:00") val createdAt: LocalDateTime, - @Schema(description = "응답 수", example = "1234") + @Schema(description = "전체 응답 수 (로그인 + 비로그인 합산)", example = "1234") val responseCount: Long, @Schema(description = "선택지 목록") val options: List, - @Schema(description = "현재 사용자의 투표 여부 (비로그인 시 false)", example = "true") + @Schema(description = "현재 사용자의 투표 여부 (로그인: accountId 기반, 비로그인: anonymous_session 쿠키 기반, 쿠키 없으면 false)", example = "true") val isVoted: Boolean, @Schema(description = "작성자 정보") val creatorInfo: CreatorInfoResponse, @@ -44,6 +46,7 @@ data class PollListResponse( responseType = poll.responseType, status = poll.status, isRevotable = poll.isRevotable, + allowAnonymousVote = poll.allowAnonymousVote, startAt = poll.startAt, endAt = poll.endAt, createdAt = poll.createdAt, diff --git a/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/repository/PollResponseRepositoryImpl.kt b/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/repository/PollResponseRepositoryImpl.kt index 0e20586..7f515e4 100644 --- a/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/repository/PollResponseRepositoryImpl.kt +++ b/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/repository/PollResponseRepositoryImpl.kt @@ -32,6 +32,10 @@ class PollResponseRepositoryImpl( return repository.findAllByPollIdAndAccountIdAndDeletedAtIsNull(pollId, accountId) } + override fun findAllByPollIdAndAnonymousSessionId(pollId: Long, anonymousSessionId: String): List { + return repository.findAllByPollIdAndAnonymousSessionIdAndDeletedAtIsNull(pollId, anonymousSessionId) + } + override fun findAllByPollId(pollId: Long): List { return repository.findAllByPollIdAndDeletedAtIsNull(pollId) } @@ -41,7 +45,7 @@ class PollResponseRepositoryImpl( } override fun countDistinctParticipantsByPollId(pollId: Long): Long { - return repository.countDistinctAccountIdByPollIdAndDeletedAtIsNull(pollId) + return repository.countDistinctParticipantsByPollId(pollId) } override fun countByOptionId(optionId: Long): Long { @@ -66,21 +70,28 @@ class PollResponseRepositoryImpl( if (pollIds.isEmpty()) return emptySet() return repository.findPollIdsByAccountIdAndPollIdIn(accountId, pollIds).toSet() } + + override fun findVotedPollIdsBySessionId(anonymousSessionId: String, pollIds: List): Set { + if (pollIds.isEmpty()) return emptySet() + return repository.findPollIdsByAnonymousSessionIdAndPollIdIn(anonymousSessionId, pollIds).toSet() + } } interface JpaPollResponseRepository : JpaRepository { fun findByPollIdAndAccountIdAndDeletedAtIsNull(pollId: Long, accountId: Long): PollResponse? fun findAllByPollIdAndAccountIdAndDeletedAtIsNull(pollId: Long, accountId: Long): List + fun findAllByPollIdAndAnonymousSessionIdAndDeletedAtIsNull(pollId: Long, anonymousSessionId: String): List fun findAllByPollIdAndDeletedAtIsNull(pollId: Long): List fun countByPollIdAndDeletedAtIsNull(pollId: Long): Long fun countByOptionIdAndDeletedAtIsNull(optionId: Long): Long @Query(""" - SELECT COUNT(DISTINCT pr.accountId) FROM PollResponse pr - WHERE pr.pollId = :pollId - AND pr.deletedAt IS NULL + SELECT + (SELECT COUNT(DISTINCT pr.accountId) FROM PollResponse pr WHERE pr.pollId = :pollId AND pr.deletedAt IS NULL AND pr.accountId IS NOT NULL) + + + (SELECT COUNT(DISTINCT pr.anonymousSessionId) FROM PollResponse pr WHERE pr.pollId = :pollId AND pr.deletedAt IS NULL AND pr.anonymousSessionId IS NOT NULL) """) - fun countDistinctAccountIdByPollIdAndDeletedAtIsNull(pollId: Long): Long + fun countDistinctParticipantsByPollId(pollId: Long): Long // No Offset 페이지네이션 (커버링 인덱스: idx_account_id) @Query(""" @@ -121,4 +132,12 @@ interface JpaPollResponseRepository : JpaRepository { AND pr.deletedAt IS NULL """) fun findPollIdsByAccountIdAndPollIdIn(accountId: Long, pollIds: List): List + + @Query(""" + SELECT DISTINCT pr.pollId FROM PollResponse pr + WHERE pr.anonymousSessionId = :anonymousSessionId + AND pr.pollId IN :pollIds + AND pr.deletedAt IS NULL + """) + fun findPollIdsByAnonymousSessionIdAndPollIdIn(anonymousSessionId: String, pollIds: List): List } diff --git a/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/service/PollQueryFacadeService.kt b/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/service/PollQueryFacadeService.kt index b665412..976ff60 100644 --- a/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/service/PollQueryFacadeService.kt +++ b/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/service/PollQueryFacadeService.kt @@ -21,8 +21,8 @@ class PollQueryFacadeService( private val pollQueryUseCase: PollQueryUseCase, ) : PollQueryFacadeUseCase { - override fun getPollDetail(pollId: Long, accountId: Long?): PollDetailResponse { - return pollQueryUseCase.getPollDetail(pollId, accountId) + override fun getPollDetail(pollId: Long, accountId: Long?, anonymousSessionId: String?): PollDetailResponse { + return pollQueryUseCase.getPollDetail(pollId, accountId, anonymousSessionId) } override fun getPollListByType( @@ -32,7 +32,8 @@ class PollQueryFacadeService( nextCursor: String?, keyword: String, sortType: PollSortType, - statusFilter: PollStatusFilter + statusFilter: PollStatusFilter, + anonymousSessionId: String? ): SliceContent { return when (pollType) { PollType.PUBLIC -> pollQueryUseCase.searchPublicPolls( @@ -41,7 +42,8 @@ class PollQueryFacadeService( size = size, nextCursor = nextCursor, sortType = sortType, - statusFilter = statusFilter + statusFilter = statusFilter, + anonymousSessionId = anonymousSessionId ) PollType.SYSTEM -> pollQueryUseCase.searchSystemPolls( accountId = accountId, @@ -49,7 +51,8 @@ class PollQueryFacadeService( size = size, nextCursor = nextCursor, sortType = sortType, - statusFilter = statusFilter + statusFilter = statusFilter, + anonymousSessionId = anonymousSessionId ) } } diff --git a/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/service/PollQueryFacadeUseCase.kt b/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/service/PollQueryFacadeUseCase.kt index 68b0096..74c1fc4 100644 --- a/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/service/PollQueryFacadeUseCase.kt +++ b/backend/election/vote/src/main/kotlin/com/futiland/vote/application/poll/service/PollQueryFacadeUseCase.kt @@ -9,7 +9,7 @@ import com.futiland.vote.util.PageContent import com.futiland.vote.util.SliceContent interface PollQueryFacadeUseCase { - fun getPollDetail(pollId: Long, accountId: Long?): PollDetailResponse + fun getPollDetail(pollId: Long, accountId: Long?, anonymousSessionId: String? = null): PollDetailResponse /** * 여론조사 목록 조회/검색 (타입별) @@ -24,7 +24,8 @@ interface PollQueryFacadeUseCase { nextCursor: String?, keyword: String = "", sortType: PollSortType = PollSortType.LATEST, - statusFilter: PollStatusFilter = PollStatusFilter.ALL + statusFilter: PollStatusFilter = PollStatusFilter.ALL, + anonymousSessionId: String? = null ): SliceContent fun getMyPolls(accountId: Long, page: Int, size: Int): PageContent diff --git a/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/entity/Poll.kt b/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/entity/Poll.kt index b8cde03..2bf1c0f 100644 --- a/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/entity/Poll.kt +++ b/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/entity/Poll.kt @@ -14,6 +14,7 @@ class Poll( val pollType: PollType, initialStatus: PollStatus, val isRevotable: Boolean, + val allowAnonymousVote: Boolean = false, val creatorAccountId: Long, var startAt: LocalDateTime? = null, var endAt: LocalDateTime? = null, @@ -40,8 +41,12 @@ class Poll( responseType: ResponseType, pollType: PollType, isRevotable: Boolean, + allowAnonymousVote: Boolean = false, creatorAccountId: Long, ): Poll { + require(!(pollType == PollType.SYSTEM && allowAnonymousVote)) { + "민심투표(SYSTEM)는 비로그인 투표를 허용할 수 없습니다" + } return Poll( title = title, description = description, @@ -49,6 +54,7 @@ class Poll( pollType = pollType, initialStatus = PollStatus.DRAFT, isRevotable = isRevotable, + allowAnonymousVote = allowAnonymousVote, creatorAccountId = creatorAccountId, startAt = null, endAt = null @@ -61,9 +67,13 @@ class Poll( responseType: ResponseType, pollType: PollType, isRevotable: Boolean, + allowAnonymousVote: Boolean = false, creatorAccountId: Long, endAt: LocalDateTime, ): Poll { + require(!(pollType == PollType.SYSTEM && allowAnonymousVote)) { + "민심투표(SYSTEM)는 비로그인 투표를 허용할 수 없습니다" + } return Poll( title = title, description = description, @@ -71,6 +81,7 @@ class Poll( pollType = pollType, initialStatus = PollStatus.IN_PROGRESS, isRevotable = isRevotable, + allowAnonymousVote = allowAnonymousVote, creatorAccountId = creatorAccountId, startAt = LocalDateTime.now(), // 현재 시간으로 자동 설정 endAt = endAt diff --git a/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/entity/PollResponse.kt b/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/entity/PollResponse.kt index 505af82..45ca768 100644 --- a/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/entity/PollResponse.kt +++ b/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/entity/PollResponse.kt @@ -7,12 +7,14 @@ import java.time.LocalDateTime @Table( indexes = [ Index(name = "idx_poll_account", columnList = "pollId, accountId, deletedAt"), + Index(name = "idx_poll_session", columnList = "pollId, anonymousSessionId, deletedAt"), Index(name = "idx_account_id", columnList = "accountId, id, deletedAt") // No Offset 페이지네이션용 ] ) class PollResponse( val pollId: Long, - val accountId: Long, + val accountId: Long?, + val anonymousSessionId: String? = null, val optionId: Long? = null, // 선택지 ID (SINGLE_CHOICE, MULTIPLE_CHOICE) var scoreValue: Int? = null, // 점수값 (SCORE) val createdAt: LocalDateTime = LocalDateTime.now(), @@ -27,16 +29,24 @@ class PollResponse( var deletedAt: LocalDateTime? = null private set + init { + require(accountId != null || anonymousSessionId != null) { + "accountId 또는 anonymousSessionId 중 하나는 반드시 있어야 합니다" + } + } + companion object { fun create( pollId: Long, - accountId: Long, + accountId: Long?, + anonymousSessionId: String? = null, optionId: Long? = null, scoreValue: Int? = null, ): PollResponse { return PollResponse( pollId = pollId, accountId = accountId, + anonymousSessionId = anonymousSessionId, optionId = optionId, scoreValue = scoreValue ) diff --git a/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/repository/PollResponseRepository.kt b/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/repository/PollResponseRepository.kt index 05d666e..0b4aa08 100644 --- a/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/repository/PollResponseRepository.kt +++ b/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/repository/PollResponseRepository.kt @@ -8,11 +8,13 @@ interface PollResponseRepository { fun findById(id: Long): PollResponse? fun findByPollIdAndAccountId(pollId: Long, accountId: Long): PollResponse? fun findAllByPollIdAndAccountId(pollId: Long, accountId: Long): List + fun findAllByPollIdAndAnonymousSessionId(pollId: Long, anonymousSessionId: String): List fun findAllByPollId(pollId: Long): List fun countByPollId(pollId: Long): Long /** * 해당 Poll에 참여한 고유 사용자 수 (multichoice에서 중복 카운트 방지) + * accountId + anonymousSessionId 모두 카운트 */ fun countDistinctParticipantsByPollId(pollId: Long): Long fun countByOptionId(optionId: Long): Long @@ -36,4 +38,9 @@ interface PollResponseRepository { * 주어진 pollIds 중 해당 사용자가 투표한 pollId 목록 조회 */ fun findVotedPollIds(accountId: Long, pollIds: List): Set + + /** + * 주어진 pollIds 중 해당 세션이 투표한 pollId 목록 조회 (비로그인 사용자용) + */ + fun findVotedPollIdsBySessionId(anonymousSessionId: String, pollIds: List): Set } diff --git a/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/service/PollCommandService.kt b/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/service/PollCommandService.kt index bf796ef..01b17c5 100644 --- a/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/service/PollCommandService.kt +++ b/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/service/PollCommandService.kt @@ -29,6 +29,7 @@ class PollCommandService( responseType = request.responseType, pollType = PollType.PUBLIC, isRevotable = request.isRevotable, + allowAnonymousVote = request.allowAnonymousVote, creatorAccountId = creatorAccountId, endAt = request.endAt ) @@ -64,6 +65,7 @@ class PollCommandService( responseType = request.responseType, pollType = PollType.PUBLIC, isRevotable = request.isRevotable, + allowAnonymousVote = request.allowAnonymousVote, creatorAccountId = creatorAccountId ) @@ -97,6 +99,7 @@ class PollCommandService( responseType = request.responseType, pollType = PollType.SYSTEM, isRevotable = request.isRevotable, + allowAnonymousVote = false, creatorAccountId = creatorAccountId, endAt = request.endAt ) diff --git a/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/service/PollQueryService.kt b/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/service/PollQueryService.kt index 48db020..083aa66 100644 --- a/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/service/PollQueryService.kt +++ b/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/service/PollQueryService.kt @@ -24,22 +24,24 @@ class PollQueryService( private val accountForPollRepository: AccountForPollRepository, ) : PollQueryUseCase { - override fun getPollDetail(pollId: Long, accountId: Long?): PollDetailResponse { + override fun getPollDetail(pollId: Long, accountId: Long?, anonymousSessionId: String?): PollDetailResponse { val poll = pollRepository.getById(pollId) val options = pollOptionRepository.findAllByPollId(pollId) val creatorInfo = accountForPollRepository.getCreatorInfoById(poll.creatorAccountId) val responseCount = pollResponseRepository.countDistinctParticipantsByPollId(pollId) - val isVoted = accountId?.let { - pollResponseRepository.findAllByPollIdAndAccountId(pollId, it).isNotEmpty() - } ?: false + val isVoted = when { + accountId != null -> pollResponseRepository.findAllByPollIdAndAccountId(pollId, accountId).isNotEmpty() + anonymousSessionId != null -> pollResponseRepository.findAllByPollIdAndAnonymousSessionId(pollId, anonymousSessionId).isNotEmpty() + else -> false + } return PollDetailResponse.from(poll, options, responseCount, isVoted, creatorInfo) } - override fun getPublicPollList(accountId: Long?, size: Int, nextCursor: String?): SliceContent { + override fun getPublicPollList(accountId: Long?, size: Int, nextCursor: String?, anonymousSessionId: String?): SliceContent { val pollsSlice = pollRepository.findAllPublicDisplayable(size, nextCursor) - return toPollListResponses(pollsSlice, accountId) + return toPollListResponses(pollsSlice, accountId, anonymousSessionId) } override fun getMyPolls(accountId: Long, page: Int, size: Int): PageContent { @@ -111,10 +113,11 @@ class PollQueryService( size: Int, nextCursor: String?, sortType: PollSortType, - statusFilter: PollStatusFilter + statusFilter: PollStatusFilter, + anonymousSessionId: String? ): SliceContent { val pollsSlice = pollRepository.searchPublicPolls(keyword, size, nextCursor, sortType, statusFilter) - return toPollListResponses(pollsSlice, accountId) + return toPollListResponses(pollsSlice, accountId, anonymousSessionId) } override fun searchSystemPolls( @@ -123,16 +126,17 @@ class PollQueryService( size: Int, nextCursor: String?, sortType: PollSortType, - statusFilter: PollStatusFilter + statusFilter: PollStatusFilter, + anonymousSessionId: String? ): SliceContent { val pollsSlice = pollRepository.searchSystemPolls(keyword, size, nextCursor, sortType, statusFilter) - return toPollListResponses(pollsSlice, accountId) + return toPollListResponses(pollsSlice, accountId, anonymousSessionId) } /** * Poll 목록을 PollListResponse 목록으로 변환 (N+1 최적화 포함) */ - private fun toPollListResponses(pollsSlice: SliceContent, accountId: Long?): SliceContent { + private fun toPollListResponses(pollsSlice: SliceContent, accountId: Long?, anonymousSessionId: String? = null): SliceContent { val polls = pollsSlice.content val pollIds = polls.map { it.id } @@ -140,9 +144,11 @@ class PollQueryService( val allOptions = pollOptionRepository.findAllByPollIdIn(pollIds) val optionsByPollId = allOptions.groupBy { it.pollId } - val votedPollIds = accountId?.let { - pollResponseRepository.findVotedPollIds(it, pollIds) - } ?: emptySet() + val votedPollIds = when { + accountId != null -> pollResponseRepository.findVotedPollIds(accountId, pollIds) + anonymousSessionId != null -> pollResponseRepository.findVotedPollIdsBySessionId(anonymousSessionId, pollIds) + else -> emptySet() + } val creatorIds = polls.map { it.creatorAccountId }.distinct() val creatorInfoMap = accountForPollRepository.getCreatorInfoByIds(creatorIds) diff --git a/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/service/PollQueryUseCase.kt b/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/service/PollQueryUseCase.kt index 0c3fca0..c05bad4 100644 --- a/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/service/PollQueryUseCase.kt +++ b/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/service/PollQueryUseCase.kt @@ -10,8 +10,8 @@ import com.futiland.vote.util.PageContent import com.futiland.vote.util.SliceContent interface PollQueryUseCase { - fun getPollDetail(pollId: Long, accountId: Long?): PollDetailResponse - fun getPublicPollList(accountId: Long?, size: Int, nextCursor: String?): SliceContent + fun getPollDetail(pollId: Long, accountId: Long?, anonymousSessionId: String? = null): PollDetailResponse + fun getPublicPollList(accountId: Long?, size: Int, nextCursor: String?, anonymousSessionId: String? = null): SliceContent /** * 내가 만든 여론조사 목록 조회 (페이지 기반 방식) @@ -37,7 +37,8 @@ interface PollQueryUseCase { size: Int, nextCursor: String?, sortType: PollSortType = PollSortType.LATEST, - statusFilter: PollStatusFilter = PollStatusFilter.ALL + statusFilter: PollStatusFilter = PollStatusFilter.ALL, + anonymousSessionId: String? = null ): SliceContent /** @@ -49,6 +50,7 @@ interface PollQueryUseCase { size: Int, nextCursor: String?, sortType: PollSortType = PollSortType.LATEST, - statusFilter: PollStatusFilter = PollStatusFilter.ALL + statusFilter: PollStatusFilter = PollStatusFilter.ALL, + anonymousSessionId: String? = null ): SliceContent } diff --git a/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/service/PollResponseCommandService.kt b/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/service/PollResponseCommandService.kt index 6791a40..c50e826 100644 --- a/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/service/PollResponseCommandService.kt +++ b/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/service/PollResponseCommandService.kt @@ -3,8 +3,10 @@ package com.futiland.vote.domain.poll.service import com.futiland.vote.application.common.httpresponse.CodeEnum import com.futiland.vote.application.exception.ApplicationException import com.futiland.vote.application.poll.dto.request.PollResponseSubmitRequest +import com.futiland.vote.domain.poll.entity.Poll import com.futiland.vote.domain.poll.entity.PollResponse import com.futiland.vote.domain.poll.entity.PollStatus +import com.futiland.vote.domain.poll.entity.PollType import com.futiland.vote.domain.poll.entity.deleteAll import com.futiland.vote.domain.poll.repository.PollOptionRepository import com.futiland.vote.domain.poll.repository.PollRepository @@ -20,9 +22,12 @@ class PollResponseCommandService( ) : PollResponseCommandUseCase { @Transactional - override fun submitResponse(pollId: Long, accountId: Long, request: PollResponseSubmitRequest): Long { + override fun submitResponse(pollId: Long, accountId: Long?, anonymousSessionId: String?, request: PollResponseSubmitRequest): Long { val poll = pollRepository.getById(pollId) + // 인증 검증 + validateAuthentication(poll, accountId, anonymousSessionId) + // 진행 중인 여론조사만 응답 가능 if (poll.status != PollStatus.IN_PROGRESS) { throw ApplicationException( @@ -32,7 +37,7 @@ class PollResponseCommandService( } // 중복 응답 체크 및 재투표 처리 - val existingResponses = pollResponseRepository.findAllByPollIdAndAccountId(pollId, accountId) + val existingResponses = findExistingResponses(pollId, accountId, anonymousSessionId) if (existingResponses.isNotEmpty()) { if (!poll.isRevotable) { throw ApplicationException( @@ -49,47 +54,18 @@ class PollResponseCommandService( poll.validateResponse(request) // 응답 저장 - val responses = when (request) { - is PollResponseSubmitRequest.SingleChoice -> { - listOf( - PollResponse.create( - pollId = pollId, - accountId = accountId, - optionId = request.optionId, - scoreValue = null - ) - ) - } - is PollResponseSubmitRequest.MultipleChoice -> { - request.optionIds.map { optionId -> - PollResponse.create( - pollId = pollId, - accountId = accountId, - optionId = optionId, - scoreValue = null - ) - } - } - is PollResponseSubmitRequest.Score -> { - listOf( - PollResponse.create( - pollId = pollId, - accountId = accountId, - optionId = null, - scoreValue = request.scoreValue - ) - ) - } - } - + val responses = createResponses(pollId, accountId, anonymousSessionId, request) val savedResponses = pollResponseRepository.saveAll(responses) return savedResponses.first().id } @Transactional - override fun updateResponse(pollId: Long, accountId: Long, request: PollResponseSubmitRequest): Long { + override fun updateResponse(pollId: Long, accountId: Long?, anonymousSessionId: String?, request: PollResponseSubmitRequest): Long { val poll = pollRepository.getById(pollId) + // 인증 검증 + validateAuthentication(poll, accountId, anonymousSessionId) + // 진행 중인 여론조사만 수정 가능 if (poll.status != PollStatus.IN_PROGRESS) { throw ApplicationException( @@ -107,7 +83,7 @@ class PollResponseCommandService( } // 기존 응답 조회 (다중 선택의 경우 여러 레코드 존재 가능) - val existingResponses = pollResponseRepository.findAllByPollIdAndAccountId(pollId, accountId) + val existingResponses = findExistingResponses(pollId, accountId, anonymousSessionId) if (existingResponses.isEmpty()) { throw ApplicationException( code = CodeEnum.FRS_001, @@ -124,12 +100,88 @@ class PollResponseCommandService( pollResponseRepository.saveAll(existingResponses) // 새로운 응답 생성 - val responses = when (request) { + val responses = createResponses(pollId, accountId, anonymousSessionId, request) + val savedResponses = pollResponseRepository.saveAll(responses) + return savedResponses.first().id + } + + @Transactional + override fun deleteResponse(pollId: Long, accountId: Long?, anonymousSessionId: String?) { + val poll = pollRepository.getById(pollId) + + // 인증 검증 + validateAuthentication(poll, accountId, anonymousSessionId) + + val existingResponses = findExistingResponses(pollId, accountId, anonymousSessionId) + if (existingResponses.isEmpty()) { + throw ApplicationException( + code = CodeEnum.FRS_001, + message = "응답 내역이 없습니다" + ) + } + + existingResponses.deleteAll() + pollResponseRepository.saveAll(existingResponses) + } + + /** + * 인증 검증: 비로그인 투표 허용 여부 확인 + */ + private fun validateAuthentication(poll: Poll, accountId: Long?, anonymousSessionId: String?) { + if (accountId != null) return // 로그인 사용자는 항상 허용 + + // 비로그인 사용자 + if (anonymousSessionId == null) { + throw ApplicationException( + code = CodeEnum.FRS_002, + message = "로그인이 필요합니다" + ) + } + + if (poll.pollType == PollType.SYSTEM) { + throw ApplicationException( + code = CodeEnum.FRS_002, + message = "민심투표는 로그인이 필요합니다" + ) + } + + if (!poll.allowAnonymousVote) { + throw ApplicationException( + code = CodeEnum.FRS_002, + message = "이 여론조사는 로그인이 필요합니다" + ) + } + } + + /** + * 기존 응답 조회 (로그인/비로그인 분기) + */ + private fun findExistingResponses(pollId: Long, accountId: Long?, anonymousSessionId: String?): List { + return if (accountId != null) { + pollResponseRepository.findAllByPollIdAndAccountId(pollId, accountId) + } else if (anonymousSessionId != null) { + pollResponseRepository.findAllByPollIdAndAnonymousSessionId(pollId, anonymousSessionId) + } else { + emptyList() + } + } + + /** + * 응답 생성 (로그인/비로그인 분기) + */ + private fun createResponses( + pollId: Long, + accountId: Long?, + anonymousSessionId: String?, + request: PollResponseSubmitRequest + ): List { + return when (request) { is PollResponseSubmitRequest.SingleChoice -> { listOf( PollResponse.create( pollId = pollId, accountId = accountId, + anonymousSessionId = anonymousSessionId, optionId = request.optionId, scoreValue = null ) @@ -140,6 +192,7 @@ class PollResponseCommandService( PollResponse.create( pollId = pollId, accountId = accountId, + anonymousSessionId = anonymousSessionId, optionId = optionId, scoreValue = null ) @@ -150,27 +203,13 @@ class PollResponseCommandService( PollResponse.create( pollId = pollId, accountId = accountId, + anonymousSessionId = anonymousSessionId, optionId = null, scoreValue = request.scoreValue ) ) } } - - val savedResponses = pollResponseRepository.saveAll(responses) - return savedResponses.first().id - } - - @Transactional - override fun deleteResponse(pollId: Long, accountId: Long) { - val pollResponse = pollResponseRepository.findByPollIdAndAccountId(pollId, accountId) - ?: throw ApplicationException( - code = CodeEnum.FRS_001, - message = "응답 내역이 없습니다" - ) - - pollResponse.delete() - pollResponseRepository.save(pollResponse) } private fun validateRequestedOptions(pollId: Long, request: PollResponseSubmitRequest) { diff --git a/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/service/PollResponseCommandUseCase.kt b/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/service/PollResponseCommandUseCase.kt index c12cc03..b4f0d85 100644 --- a/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/service/PollResponseCommandUseCase.kt +++ b/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/service/PollResponseCommandUseCase.kt @@ -3,7 +3,7 @@ package com.futiland.vote.domain.poll.service import com.futiland.vote.application.poll.dto.request.PollResponseSubmitRequest interface PollResponseCommandUseCase { - fun submitResponse(pollId: Long, accountId: Long, request: PollResponseSubmitRequest): Long - fun updateResponse(pollId: Long, accountId: Long, request: PollResponseSubmitRequest): Long - fun deleteResponse(pollId: Long, accountId: Long) + fun submitResponse(pollId: Long, accountId: Long?, anonymousSessionId: String?, request: PollResponseSubmitRequest): Long + fun updateResponse(pollId: Long, accountId: Long?, anonymousSessionId: String?, request: PollResponseSubmitRequest): Long + fun deleteResponse(pollId: Long, accountId: Long?, anonymousSessionId: String?) } diff --git a/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/service/PollResultQueryService.kt b/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/service/PollResultQueryService.kt index 588656c..8431c5f 100644 --- a/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/service/PollResultQueryService.kt +++ b/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/service/PollResultQueryService.kt @@ -6,6 +6,7 @@ import com.futiland.vote.application.poll.dto.response.MyPollResponse import com.futiland.vote.application.poll.dto.response.OptionResultResponse import com.futiland.vote.application.poll.dto.response.PollResultResponse import com.futiland.vote.application.poll.dto.response.ScoreResultResponse +import com.futiland.vote.domain.poll.entity.PollResponse import com.futiland.vote.domain.poll.entity.ResponseType import com.futiland.vote.domain.poll.repository.PollOptionRepository import com.futiland.vote.domain.poll.repository.PollRepository @@ -23,11 +24,11 @@ class PollResultQueryService( private val pollResponseRepository: PollResponseRepository, ) : PollResultQueryUseCase { - override fun getPollResult(pollId: Long, accountId: Long): PollResultResponse { + override fun getPollResult(pollId: Long, accountId: Long?, anonymousSessionId: String?): PollResultResponse { val poll = pollRepository.getById(pollId) val totalResponseCount = pollResponseRepository.countDistinctParticipantsByPollId(pollId) - val myResponse = getMyResponse(pollId, accountId, poll.responseType) + val myResponse = getMyResponse(pollId, accountId, anonymousSessionId, poll.responseType) return when (poll.responseType) { ResponseType.SINGLE_CHOICE, ResponseType.MULTIPLE_CHOICE -> { @@ -88,8 +89,13 @@ class PollResultQueryService( } } - private fun getMyResponse(pollId: Long, accountId: Long, responseType: ResponseType): MyPollResponse { - val myResponses = pollResponseRepository.findAllByPollIdAndAccountId(pollId, accountId) + private fun getMyResponse(pollId: Long, accountId: Long?, anonymousSessionId: String?, responseType: ResponseType): MyPollResponse { + val myResponses: List = when { + accountId != null -> pollResponseRepository.findAllByPollIdAndAccountId(pollId, accountId) + anonymousSessionId != null -> pollResponseRepository.findAllByPollIdAndAnonymousSessionId(pollId, anonymousSessionId) + else -> emptyList() + } + if (myResponses.isEmpty()) { throw ApplicationException( code = CodeEnum.FRS_002, diff --git a/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/service/PollResultQueryUseCase.kt b/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/service/PollResultQueryUseCase.kt index fd44051..8b80d6d 100644 --- a/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/service/PollResultQueryUseCase.kt +++ b/backend/election/vote/src/main/kotlin/com/futiland/vote/domain/poll/service/PollResultQueryUseCase.kt @@ -3,5 +3,5 @@ package com.futiland.vote.domain.poll.service import com.futiland.vote.application.poll.dto.response.PollResultResponse interface PollResultQueryUseCase { - fun getPollResult(pollId: Long, accountId: Long): PollResultResponse + fun getPollResult(pollId: Long, accountId: Long?, anonymousSessionId: String? = null): PollResultResponse } diff --git a/backend/election/vote/src/main/resources/sql/poll/poll.sql b/backend/election/vote/src/main/resources/sql/poll/poll.sql index 1d5ecae..3d6eae4 100644 --- a/backend/election/vote/src/main/resources/sql/poll/poll.sql +++ b/backend/election/vote/src/main/resources/sql/poll/poll.sql @@ -7,6 +7,7 @@ CREATE TABLE poll poll_type ENUM('SYSTEM', 'PUBLIC') NOT NULL COMMENT '여론조사 타입', status ENUM('DRAFT', 'IN_PROGRESS', 'EXPIRED', 'CANCELLED', 'DELETED') NOT NULL COMMENT '상태', is_revotable BOOLEAN NOT NULL COMMENT '재투표 가능 여부', + allow_anonymous_vote BOOLEAN NOT NULL DEFAULT FALSE COMMENT '비로그인 투표 허용 여부', creator_account_id BIGINT NOT NULL COMMENT '생성자 ID', start_at DATETIME NULL COMMENT '시작일시', end_at DATETIME NULL COMMENT '종료일시', @@ -14,3 +15,8 @@ CREATE TABLE poll updated_at DATETIME NULL COMMENT '수정일', deleted_at DATETIME NULL COMMENT '삭제일' ); + +-- 2026-02-21 비로그인 투표 허용 여부 컬럼 추가 +ALTER TABLE poll +ADD COLUMN allow_anonymous_vote BOOLEAN NOT NULL DEFAULT FALSE COMMENT '비로그인 투표 허용 여부' +AFTER is_revotable; diff --git a/backend/election/vote/src/main/resources/sql/poll/poll_response.sql b/backend/election/vote/src/main/resources/sql/poll/poll_response.sql index 3a64715..feaa3e0 100644 --- a/backend/election/vote/src/main/resources/sql/poll/poll_response.sql +++ b/backend/election/vote/src/main/resources/sql/poll/poll_response.sql @@ -2,15 +2,28 @@ CREATE TABLE poll_response ( id BIGINT AUTO_INCREMENT PRIMARY KEY NOT NULL COMMENT '인덱싱 컬럼', poll_id BIGINT NOT NULL COMMENT '여론조사 ID', - account_id BIGINT NOT NULL COMMENT '응답자 ID', - option_id BIGINT NULL COMMENT '선택지 ID (SINGLE_CHOICE, MULTIPLE_CHOICE)', + account_id BIGINT NULL COMMENT '응답자 ID (비로그인 시 NULL)', + anonymous_session_id VARCHAR(255) NULL COMMENT '비로그인 사용자 세션 ID', + option_id BIGINT NULL COMMENT '선택지 ID (SINGLE_CHOICE, MULTIPLE_CHOICE)', score_value INT NULL COMMENT '점수 (SCORE)', created_at DATETIME NOT NULL COMMENT '생성일', updated_at DATETIME NULL COMMENT '수정일', deleted_at DATETIME NULL COMMENT '삭제일', INDEX idx_poll_account (poll_id, account_id, deleted_at) COMMENT '응답 조회용 인덱스', + INDEX idx_poll_session (poll_id, anonymous_session_id, deleted_at) COMMENT '비로그인 응답 조회용 인덱스', INDEX idx_account_id (account_id, id, deleted_at) COMMENT 'No Offset 페이지네이션용 커버링 인덱스' ); -- No Offset 페이지네이션용 커버링 인덱스 추가 CREATE INDEX idx_account_id ON poll_response (account_id, id, deleted_at); + +-- 2026-02-21 비로그인 투표 지원 +ALTER TABLE poll_response +ADD COLUMN anonymous_session_id VARCHAR(255) NULL COMMENT '비로그인 사용자 세션 ID' +AFTER account_id; + +ALTER TABLE poll_response +MODIFY COLUMN account_id BIGINT NULL COMMENT '응답자 ID (비로그인 시 NULL)'; + +ALTER TABLE poll_response +ADD INDEX idx_poll_session (poll_id, anonymous_session_id, deleted_at); diff --git a/backend/election/vote/src/test/kotlin/com/futiland/vote/application/poll/controller/PollQueryControllerIntegrationTest.kt b/backend/election/vote/src/test/kotlin/com/futiland/vote/application/poll/controller/PollQueryControllerIntegrationTest.kt index 4b2c4d3..f142dd2 100644 --- a/backend/election/vote/src/test/kotlin/com/futiland/vote/application/poll/controller/PollQueryControllerIntegrationTest.kt +++ b/backend/election/vote/src/test/kotlin/com/futiland/vote/application/poll/controller/PollQueryControllerIntegrationTest.kt @@ -120,7 +120,8 @@ class PollQueryControllerIntegrationTest { size = 10, nextCursor = null, keyword = null, - userDetails = null + userDetails = null, + pollSession = null ) // Assert @@ -140,7 +141,8 @@ class PollQueryControllerIntegrationTest { size = 10, nextCursor = null, keyword = null, - userDetails = createUserDetails(testAccount.id) + userDetails = createUserDetails(testAccount.id), + pollSession = null ) // Assert @@ -160,7 +162,8 @@ class PollQueryControllerIntegrationTest { size = 10, nextCursor = null, keyword = null, - userDetails = null + userDetails = null, + pollSession = null ) // Assert @@ -183,7 +186,8 @@ class PollQueryControllerIntegrationTest { size = 2, nextCursor = null, keyword = null, - userDetails = null + userDetails = null, + pollSession = null ) // Assert @@ -205,7 +209,8 @@ class PollQueryControllerIntegrationTest { size = 2, nextCursor = null, keyword = null, - userDetails = null + userDetails = null, + pollSession = null ) val nextCursor = firstPageResponse.data?.nextCursor @@ -221,7 +226,8 @@ class PollQueryControllerIntegrationTest { size = 2, nextCursor = nextCursor, keyword = null, - userDetails = null + userDetails = null, + pollSession = null ) // Assert - 두 번째 페이지 @@ -240,7 +246,7 @@ class PollQueryControllerIntegrationTest { createTestPollOptions(poll.id) // Act - val response = pollQueryController.getPollDetail(pollId = poll.id, userDetails = null) + val response = pollQueryController.getPollDetail(pollId = poll.id, userDetails = null, pollSession = null) // Assert assertThat(response.data).isNotNull @@ -257,7 +263,7 @@ class PollQueryControllerIntegrationTest { createTestPollOptions(poll.id) // Act - val response = pollQueryController.getPollDetail(pollId = poll.id, userDetails = null) + val response = pollQueryController.getPollDetail(pollId = poll.id, userDetails = null, pollSession = null) // Assert assertThat(response.data).isNotNull @@ -273,7 +279,7 @@ class PollQueryControllerIntegrationTest { createTestPollOptions(poll.id) // Act - val response = pollQueryController.getPollDetail(pollId = poll.id, userDetails = null) + val response = pollQueryController.getPollDetail(pollId = poll.id, userDetails = null, pollSession = null) // Assert assertThat(response.data).isNotNull @@ -285,7 +291,7 @@ class PollQueryControllerIntegrationTest { fun `존재하지 않는 여론조사 조회 실패`() { // Act & Assert assertThrows { - pollQueryController.getPollDetail(pollId = 999999L, userDetails = null) + pollQueryController.getPollDetail(pollId = 999999L, userDetails = null, pollSession = null) } } } diff --git a/backend/election/vote/src/test/kotlin/com/futiland/vote/application/poll/repository/FakePollResponseRepository.kt b/backend/election/vote/src/test/kotlin/com/futiland/vote/application/poll/repository/FakePollResponseRepository.kt index 4fe1559..8143820 100644 --- a/backend/election/vote/src/test/kotlin/com/futiland/vote/application/poll/repository/FakePollResponseRepository.kt +++ b/backend/election/vote/src/test/kotlin/com/futiland/vote/application/poll/repository/FakePollResponseRepository.kt @@ -43,12 +43,10 @@ class FakePollResponseRepository : PollResponseRepository { } override fun countDistinctParticipantsByPollId(pollId: Long): Long { - return responses.values - .filter { it.pollId == pollId && it.deletedAt == null } - .map { it.accountId } - .distinct() - .count() - .toLong() + val activeResponses = responses.values.filter { it.pollId == pollId && it.deletedAt == null } + val accountCount = activeResponses.mapNotNull { it.accountId }.distinct().count() + val sessionCount = activeResponses.mapNotNull { it.anonymousSessionId }.distinct().count() + return (accountCount + sessionCount).toLong() } override fun countByOptionId(optionId: Long): Long { @@ -61,6 +59,12 @@ class FakePollResponseRepository : PollResponseRepository { } } + override fun findAllByPollIdAndAnonymousSessionId(pollId: Long, anonymousSessionId: String): List { + return responses.values.filter { + it.pollId == pollId && it.anonymousSessionId == anonymousSessionId && it.deletedAt == null + } + } + override fun findParticipatedPollsByAccountId(accountId: Long, lastId: Long?, size: Int): List { val filtered = responses.values .filter { it.accountId == accountId && it.deletedAt == null } @@ -86,6 +90,13 @@ class FakePollResponseRepository : PollResponseRepository { .toSet() } + override fun findVotedPollIdsBySessionId(anonymousSessionId: String, pollIds: List): Set { + return responses.values + .filter { it.anonymousSessionId == anonymousSessionId && it.pollId in pollIds && it.deletedAt == null } + .map { it.pollId } + .toSet() + } + fun clear() { responses.clear() idGenerator.set(1) diff --git a/backend/election/vote/src/test/kotlin/com/futiland/vote/application/testdata/CreateTestDataForDevTest.kt b/backend/election/vote/src/test/kotlin/com/futiland/vote/application/testdata/CreateTestDataForDevTest.kt index 6a0c967..2264099 100644 --- a/backend/election/vote/src/test/kotlin/com/futiland/vote/application/testdata/CreateTestDataForDevTest.kt +++ b/backend/election/vote/src/test/kotlin/com/futiland/vote/application/testdata/CreateTestDataForDevTest.kt @@ -146,7 +146,7 @@ class CreateTestDataForDevTest { try { val request = PollResponseSubmitRequest.SingleChoice(optionId = selectedOption.id) - pollResponseCommandService.submitResponse(pollId, account.id, request) + pollResponseCommandService.submitResponse(pollId, account.id, null, request) logger.info("투표 완료: 계정ID=${account.id} -> '${selectedOption.optionText}'") voteCount++ } catch (e: Exception) { @@ -179,7 +179,7 @@ class CreateTestDataForDevTest { try { val request = PollResponseSubmitRequest.MultipleChoice(optionIds = selectedOptions.map { it.id }) - pollResponseCommandService.submitResponse(pollId, account.id, request) + pollResponseCommandService.submitResponse(pollId, account.id, null, request) logger.info("투표 완료: 계정ID=${account.id} -> ${selectedOptions.map { it.optionText }}") voteCount++ } catch (e: Exception) { @@ -206,7 +206,7 @@ class CreateTestDataForDevTest { try { val request = PollResponseSubmitRequest.Score(scoreValue = score) - pollResponseCommandService.submitResponse(pollId, account.id, request) + pollResponseCommandService.submitResponse(pollId, account.id, null, request) logger.info("투표 완료: 계정ID=${account.id} -> ${score}점") voteCount++ } catch (e: Exception) { diff --git a/backend/election/vote/src/test/kotlin/com/futiland/vote/domain/poll/service/PollResponseCommandServiceTest.kt b/backend/election/vote/src/test/kotlin/com/futiland/vote/domain/poll/service/PollResponseCommandServiceTest.kt index 191791d..74f5c0b 100644 --- a/backend/election/vote/src/test/kotlin/com/futiland/vote/domain/poll/service/PollResponseCommandServiceTest.kt +++ b/backend/election/vote/src/test/kotlin/com/futiland/vote/domain/poll/service/PollResponseCommandServiceTest.kt @@ -88,6 +88,7 @@ class PollResponseCommandServiceTest { val responseId = pollResponseCommandUseCase.submitResponse( pollId = poll.id, accountId = 200L, + anonymousSessionId = null, request = request ) @@ -127,6 +128,7 @@ class PollResponseCommandServiceTest { pollResponseCommandUseCase.submitResponse( pollId = poll.id, accountId = 200L, + anonymousSessionId = null, request = request ) } @@ -155,11 +157,11 @@ class PollResponseCommandServiceTest { ) // 첫 번째 응답 - pollResponseCommandUseCase.submitResponse(poll.id, 200L, request) + pollResponseCommandUseCase.submitResponse(poll.id, 200L, null, request) // Act & Assert - 두 번째 응답 시도 assertThrows { - pollResponseCommandUseCase.submitResponse(poll.id, 200L, request) + pollResponseCommandUseCase.submitResponse(poll.id, 200L, null, request) } } @@ -186,7 +188,7 @@ class PollResponseCommandServiceTest { ) // 첫 번째 응답 - pollResponseCommandUseCase.submitResponse(poll.id, 200L, firstRequest) + pollResponseCommandUseCase.submitResponse(poll.id, 200L, null, firstRequest) // 두 번째 응답 (재투표) val secondRequest = PollResponseSubmitRequest.SingleChoice( @@ -194,7 +196,7 @@ class PollResponseCommandServiceTest { ) // Act - val responseId = pollResponseCommandUseCase.submitResponse(poll.id, 200L, secondRequest) + val responseId = pollResponseCommandUseCase.submitResponse(poll.id, 200L, null, secondRequest) // Assert assertThat(responseId).isGreaterThan(0) @@ -233,6 +235,7 @@ class PollResponseCommandServiceTest { val responseId = pollResponseCommandUseCase.submitResponse( pollId = poll.id, accountId = 200L, + anonymousSessionId = null, request = request ) @@ -266,7 +269,7 @@ class PollResponseCommandServiceTest { // Act & Assert assertThrows { - pollResponseCommandUseCase.submitResponse(poll.id, 200L, request) + pollResponseCommandUseCase.submitResponse(poll.id, 200L, null, request) } } } @@ -296,6 +299,7 @@ class PollResponseCommandServiceTest { val responseId = pollResponseCommandUseCase.submitResponse( pollId = poll.id, accountId = 200L, + anonymousSessionId = null, request = request ) @@ -325,7 +329,7 @@ class PollResponseCommandServiceTest { // Act & Assert val exception = assertThrows { - pollResponseCommandUseCase.submitResponse(poll.id, 200L, request) + pollResponseCommandUseCase.submitResponse(poll.id, 200L, null, request) } assertThat(exception.code).isEqualTo(CodeEnum.FRS_003) assertThat(exception.message).contains("점수는 0~10 사이여야 합니다") @@ -352,7 +356,7 @@ class PollResponseCommandServiceTest { // Act & Assert val exception = assertThrows { - pollResponseCommandUseCase.submitResponse(poll.id, 200L, request) + pollResponseCommandUseCase.submitResponse(poll.id, 200L, null, request) } assertThat(exception.code).isEqualTo(CodeEnum.FRS_003) assertThat(exception.message).contains("점수는 0~10 사이여야 합니다") @@ -383,7 +387,7 @@ class PollResponseCommandServiceTest { val firstRequest = PollResponseSubmitRequest.SingleChoice( optionId = poll.options[0].id ) - pollResponseCommandUseCase.submitResponse(poll.id, 200L, firstRequest) + pollResponseCommandUseCase.submitResponse(poll.id, 200L, null, firstRequest) // 응답 수정 val updateRequest = PollResponseSubmitRequest.SingleChoice( @@ -391,7 +395,7 @@ class PollResponseCommandServiceTest { ) // Act - val responseId = pollResponseCommandUseCase.updateResponse(poll.id, 200L, updateRequest) + val responseId = pollResponseCommandUseCase.updateResponse(poll.id, 200L, null, updateRequest) // Assert val savedResponse = pollResponseRepository.findById(responseId) @@ -420,7 +424,7 @@ class PollResponseCommandServiceTest { val firstRequest = PollResponseSubmitRequest.SingleChoice( optionId = poll.options[0].id ) - pollResponseCommandUseCase.submitResponse(poll.id, 200L, firstRequest) + pollResponseCommandUseCase.submitResponse(poll.id, 200L, null, firstRequest) // 응답 수정 시도 val updateRequest = PollResponseSubmitRequest.SingleChoice( @@ -429,7 +433,7 @@ class PollResponseCommandServiceTest { // Act & Assert assertThrows { - pollResponseCommandUseCase.updateResponse(poll.id, 200L, updateRequest) + pollResponseCommandUseCase.updateResponse(poll.id, 200L, null, updateRequest) } } } @@ -465,6 +469,7 @@ class PollResponseCommandServiceTest { pollResponseCommandUseCase.submitResponse( pollId = poll.id, accountId = 200L, + anonymousSessionId = null, request = request ) } @@ -500,6 +505,7 @@ class PollResponseCommandServiceTest { pollResponseCommandUseCase.submitResponse( pollId = poll.id, accountId = 200L, + anonymousSessionId = null, request = request ) } @@ -534,6 +540,7 @@ class PollResponseCommandServiceTest { pollResponseCommandUseCase.submitResponse( pollId = poll.id, accountId = 200L, + anonymousSessionId = null, request = request ) } @@ -562,7 +569,7 @@ class PollResponseCommandServiceTest { val validRequest = PollResponseSubmitRequest.SingleChoice( optionId = poll.options[0].id ) - pollResponseCommandUseCase.submitResponse(poll.id, 200L, validRequest) + pollResponseCommandUseCase.submitResponse(poll.id, 200L, null, validRequest) // 존재하지 않는 optionId로 수정 시도 val invalidRequest = PollResponseSubmitRequest.SingleChoice( @@ -574,6 +581,7 @@ class PollResponseCommandServiceTest { pollResponseCommandUseCase.updateResponse( pollId = poll.id, accountId = 200L, + anonymousSessionId = null, request = invalidRequest ) }