diff --git a/src/main/java/issueissyu/backend/domain/alarm/controller/UserAlarmController.java b/src/main/java/issueissyu/backend/domain/alarm/controller/UserAlarmController.java index 84f3e459..e2b57e81 100644 --- a/src/main/java/issueissyu/backend/domain/alarm/controller/UserAlarmController.java +++ b/src/main/java/issueissyu/backend/domain/alarm/controller/UserAlarmController.java @@ -85,7 +85,7 @@ public ApiResponse sendLikeAlarm( description = """ 동네 인증 지역의 축제(FESTIVAL) 핀에 대한 푸시 알람을 요청자에게 전송합니다. - event_start_time 이 현재 시각 ±12시간 이내인 핀만 대상이며, 제목·본문은 서버에서 고정값으로 생성합니다. + event_start_time 이 현재 시각 24시간 이내인 핀만 대상이며, 제목·본문은 서버에서 고정값으로 생성합니다. user.event_alarm_active 가 false 이면 EVENT_ALARM_403 을 반환합니다. EVENT_ALARM_404_1(동네 인증 없음), EVENT_ALARM_404_2(대상 축제 없음), EVENT_ALARM_404_3(커뮤니티 없음). 알람 클릭 시 GET /api/communities/{communityId} 로 이동합니다. @@ -101,7 +101,7 @@ public ApiResponse sendEventAlarm(@AuthenticationPrincipal description = """ 동네 인증 지역의 가게(STORE) 핀에 대한 푸시 알람을 요청자에게 전송합니다. - event_start_time 이 현재 시각 ±12시간 이내인 핀만 대상이며, 제목·본문은 서버에서 고정값으로 생성합니다. + event_start_time 이 현재 시각 24시간 이내인 핀만 대상이며, 제목·본문은 서버에서 고정값으로 생성합니다. user.store_alarm_active 가 false 이면 STORE_ALARM_403 을 반환합니다. 알람 클릭 시 GET /api/communities/{communityId} 로 이동합니다. """) diff --git a/src/main/java/issueissyu/backend/domain/alarm/service/command/RegionalAlarmCommandServiceImpl.java b/src/main/java/issueissyu/backend/domain/alarm/service/command/RegionalAlarmCommandServiceImpl.java index 4e923321..1c17144c 100644 --- a/src/main/java/issueissyu/backend/domain/alarm/service/command/RegionalAlarmCommandServiceImpl.java +++ b/src/main/java/issueissyu/backend/domain/alarm/service/command/RegionalAlarmCommandServiceImpl.java @@ -183,10 +183,9 @@ private void dispatchFcmBatch(List payloads, String alar fcmService.sendNotificationsBatchAsync(payloads); log.info("[RegionalAlarm] {} FCM batch dispatched count={}", alarmType, payloads.size()); } - private static LocalDateTime[] alarmTimeWindow() { - LocalDateTime now = LocalDateTime.now(); - return new LocalDateTime[] {now.minusHours(12), now.plusHours(12)}; + LocalDateTime now = LocalDateTime.now(java.time.ZoneId.of("Asia/Seoul")); + return new LocalDateTime[] {now.minusHours(24), now}; } private User findUser(String uid) { diff --git a/src/main/java/issueissyu/backend/domain/user/controller/UserController.java b/src/main/java/issueissyu/backend/domain/user/controller/UserController.java index 8912d0fd..e22be6cf 100644 --- a/src/main/java/issueissyu/backend/domain/user/controller/UserController.java +++ b/src/main/java/issueissyu/backend/domain/user/controller/UserController.java @@ -8,12 +8,14 @@ import issueissyu.backend.domain.user.dto.res.UserAlarmStateResDTO; import issueissyu.backend.domain.user.dto.res.UserAlarmToggleResDTO; import issueissyu.backend.domain.user.dto.res.UserMyPinsResDTO; +import issueissyu.backend.domain.user.dto.res.UserMySolversResDTO; import issueissyu.backend.domain.user.enums.UserAlarmType; import issueissyu.backend.domain.alarm.exception.code.AlarmSuccessCode; import issueissyu.backend.domain.user.exception.code.UserSuccessCode; import issueissyu.backend.domain.user.service.command.UserCommandService; import issueissyu.backend.domain.user.service.query.UserAlarmStateQueryService; import issueissyu.backend.domain.user.service.query.UserPinQueryService; +import issueissyu.backend.domain.user.service.query.UserSolverQueryService; import issueissyu.backend.global.api.ApiResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -33,6 +35,7 @@ public class UserController { private final UserPinQueryService userPinQueryService; + private final UserSolverQueryService userSolverQueryService; private final UserAlarmStateQueryService userAlarmStateQueryService; private final UserCommandService userCommandService; @@ -87,4 +90,17 @@ public ApiResponse getMyPins( @RequestParam(required = false) String cursor) { return ApiResponse.onSuccess(UserSuccessCode.USER_PIN_200, userPinQueryService.getMyPins(uid, size, cursor)); } + + @Operation( + summary = "내 시민해결사 참여 핀 조회", + description = + "로그인한 사용자가 시민해결사(EN_ROUTE)로 참여 중인 핀을 problem_solver.created_at 내림차순으로 커서 페이징 조회합니다.") + @GetMapping("/me/solvers") + public ApiResponse getMySolvers( + @AuthenticationPrincipal String uid, + @RequestParam(required = false) Integer size, + @RequestParam(required = false) String cursor) { + return ApiResponse.onSuccess( + UserSuccessCode.USER_SOLVER_200, userSolverQueryService.getMySolvers(uid, size, cursor)); + } } diff --git a/src/main/java/issueissyu/backend/domain/user/dto/res/UserMySolverItemResDTO.java b/src/main/java/issueissyu/backend/domain/user/dto/res/UserMySolverItemResDTO.java new file mode 100644 index 00000000..e3e16393 --- /dev/null +++ b/src/main/java/issueissyu/backend/domain/user/dto/res/UserMySolverItemResDTO.java @@ -0,0 +1,11 @@ +package issueissyu.backend.domain.user.dto.res; + +import com.fasterxml.jackson.annotation.JsonFormat; +import java.time.LocalDateTime; + +public record UserMySolverItemResDTO( + Long pinId, + String pinTitle, + String pinDetailAddress, + String issuePinState, + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss.SSSSSS") LocalDateTime createdAt) {} diff --git a/src/main/java/issueissyu/backend/domain/user/dto/res/UserMySolversResDTO.java b/src/main/java/issueissyu/backend/domain/user/dto/res/UserMySolversResDTO.java new file mode 100644 index 00000000..f910d25e --- /dev/null +++ b/src/main/java/issueissyu/backend/domain/user/dto/res/UserMySolversResDTO.java @@ -0,0 +1,5 @@ +package issueissyu.backend.domain.user.dto.res; + +import java.util.List; + +public record UserMySolversResDTO(List pins, UserMyPinPageInfoResDTO pageInfo) {} diff --git a/src/main/java/issueissyu/backend/domain/user/exception/code/UserErrorCode.java b/src/main/java/issueissyu/backend/domain/user/exception/code/UserErrorCode.java index 235e1ac6..9024cf8c 100644 --- a/src/main/java/issueissyu/backend/domain/user/exception/code/UserErrorCode.java +++ b/src/main/java/issueissyu/backend/domain/user/exception/code/UserErrorCode.java @@ -11,6 +11,8 @@ public enum UserErrorCode implements BaseErrorCode { USER_PIN_400_1(HttpStatus.BAD_REQUEST, "USER_PIN_400_1", "조회 불가능한 사이즈 입니다."), USER_PIN_400_2(HttpStatus.BAD_REQUEST, "USER_PIN_400_2", "조회 불가능한 cursor 입니다."), + USER_SOLVER_400_1(HttpStatus.BAD_REQUEST, "USER_SOLVER_400_1", "조회 불가능한 사이즈 입니다."), + USER_SOLVER_400_2(HttpStatus.BAD_REQUEST, "USER_SOLVER_400_2", "조회 불가능한 cursor 입니다."), USER_NICKNAME_400(HttpStatus.BAD_REQUEST, "USER_NICKNAME_400", "닉네임은 15자 이내의 영문, 숫자, 한글만 사용 가능합니다."), USER_NICKNAME_409(HttpStatus.CONFLICT, "USER_NICKNAME_409", "이미 사용 중인 닉네임입니다."), USER_ALARM_400(HttpStatus.BAD_REQUEST, "USER_ALARM_400", "존재하지 않는 알람 설정입니다."); diff --git a/src/main/java/issueissyu/backend/domain/user/exception/code/UserSuccessCode.java b/src/main/java/issueissyu/backend/domain/user/exception/code/UserSuccessCode.java index 522bb36e..4ddb0886 100644 --- a/src/main/java/issueissyu/backend/domain/user/exception/code/UserSuccessCode.java +++ b/src/main/java/issueissyu/backend/domain/user/exception/code/UserSuccessCode.java @@ -10,6 +10,7 @@ public enum UserSuccessCode implements BaseSuccessCode { USER_PIN_200(HttpStatus.OK, "USER_PIN_200", "내 핀 조회에 성공했습니다."), + USER_SOLVER_200(HttpStatus.OK, "USER_SOLVER_200", "내 시민해결사 조회에 성공했습니다."), USER_NICKNAME_200(HttpStatus.OK, "USER_NICKNAME_200", "닉네임 변경에 성공했습니다."), USER_ALARM_200_1(HttpStatus.OK, "USER_ALARM_200_1", "핀 좋아요 알람 비/활성화에 성공했습니다."), USER_ALARM_200_2(HttpStatus.OK, "USER_ALARM_200_2", "이벤트 알람 비/활성화에 성공했습니다."), diff --git a/src/main/java/issueissyu/backend/domain/user/repository/UserMySolverRepository.java b/src/main/java/issueissyu/backend/domain/user/repository/UserMySolverRepository.java new file mode 100644 index 00000000..1b44a74d --- /dev/null +++ b/src/main/java/issueissyu/backend/domain/user/repository/UserMySolverRepository.java @@ -0,0 +1,51 @@ +package issueissyu.backend.domain.user.repository; + +import issueissyu.backend.domain.issue.entity.ProblemSolver; +import java.time.LocalDateTime; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface UserMySolverRepository extends JpaRepository { + + @Query( + value = + """ + SELECT p.pin_id AS pinId, + p.pin_title AS pinTitle, + pl.detail_address AS pinDetailAddress, + ip.issue_pin_state AS issuePinState, + ps.created_at AS createdAt, + ps.problem_solver_id AS problemSolverId + FROM problem_solver ps + JOIN issue_pin ip ON ip.issue_pin_id = ps.issue_pin_id + JOIN pin p ON p.pin_id = ip.pin_id + LEFT JOIN LATERAL ( + SELECT pl2.detail_address + FROM pin_location pl2 + WHERE pl2.pin_id = p.pin_id + ORDER BY pl2.pin_location_id ASC + LIMIT 1 + ) pl ON TRUE + WHERE ps.uid = :uid + AND ps.problem_solve_state = 'EN_ROUTE' + AND ( + NOT CAST(:applyCursor AS boolean) + OR ps.created_at < CAST(:cursorCreatedAt AS timestamp) + OR ( + ps.created_at = CAST(:cursorCreatedAt AS timestamp) + AND ps.problem_solver_id < :cursorProblemSolverId + ) + ) + ORDER BY ps.created_at DESC, ps.problem_solver_id DESC + LIMIT :limit + """, + nativeQuery = true) + List findMySolvers( + @Param("uid") String uid, + @Param("applyCursor") boolean applyCursor, + @Param("cursorCreatedAt") LocalDateTime cursorCreatedAt, + @Param("cursorProblemSolverId") long cursorProblemSolverId, + @Param("limit") int limit); +} diff --git a/src/main/java/issueissyu/backend/domain/user/repository/UserMySolverRow.java b/src/main/java/issueissyu/backend/domain/user/repository/UserMySolverRow.java new file mode 100644 index 00000000..fa95c531 --- /dev/null +++ b/src/main/java/issueissyu/backend/domain/user/repository/UserMySolverRow.java @@ -0,0 +1,17 @@ +package issueissyu.backend.domain.user.repository; + +import java.time.LocalDateTime; + +public interface UserMySolverRow { + Long getPinId(); + + String getPinTitle(); + + String getPinDetailAddress(); + + String getIssuePinState(); + + LocalDateTime getCreatedAt(); + + Long getProblemSolverId(); +} diff --git a/src/main/java/issueissyu/backend/domain/user/service/query/UserSolverQueryService.java b/src/main/java/issueissyu/backend/domain/user/service/query/UserSolverQueryService.java new file mode 100644 index 00000000..360ff485 --- /dev/null +++ b/src/main/java/issueissyu/backend/domain/user/service/query/UserSolverQueryService.java @@ -0,0 +1,8 @@ +package issueissyu.backend.domain.user.service.query; + +import issueissyu.backend.domain.user.dto.res.UserMySolversResDTO; + +public interface UserSolverQueryService { + + UserMySolversResDTO getMySolvers(String uid, Integer size, String cursor); +} diff --git a/src/main/java/issueissyu/backend/domain/user/service/query/UserSolverQueryServiceImpl.java b/src/main/java/issueissyu/backend/domain/user/service/query/UserSolverQueryServiceImpl.java new file mode 100644 index 00000000..6f2d5781 --- /dev/null +++ b/src/main/java/issueissyu/backend/domain/user/service/query/UserSolverQueryServiceImpl.java @@ -0,0 +1,88 @@ +package issueissyu.backend.domain.user.service.query; + +import issueissyu.backend.domain.user.dto.res.UserMyPinPageInfoResDTO; +import issueissyu.backend.domain.user.dto.res.UserMySolverItemResDTO; +import issueissyu.backend.domain.user.dto.res.UserMySolversResDTO; +import issueissyu.backend.domain.user.exception.UserException; +import issueissyu.backend.domain.user.exception.code.UserErrorCode; +import issueissyu.backend.domain.user.repository.UserMySolverRepository; +import issueissyu.backend.domain.user.repository.UserMySolverRow; +import issueissyu.backend.domain.user.repository.UserRepository; +import issueissyu.backend.domain.user.support.UserSolverCursorCodec; +import issueissyu.backend.global.api.code.GeneralErrorCode; +import issueissyu.backend.global.exception.GeneralException; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserSolverQueryServiceImpl implements UserSolverQueryService { + + private static final int SIZE_DEFAULT = 10; + private static final int SIZE_MIN = 1; + private static final int SIZE_MAX = 100; + + private static final LocalDateTime CURSOR_DUMMY_TIME = LocalDateTime.of(1970, 1, 1, 0, 0, 0); + + private final UserRepository userRepository; + private final UserMySolverRepository userMySolverRepository; + private final UserSolverCursorCodec userSolverCursorCodec; + + @Override + public UserMySolversResDTO getMySolvers(String uid, Integer size, String cursor) { + int pageSize = resolveSize(size); + + if (!userRepository.existsById(uid)) { + throw GeneralException.of(GeneralErrorCode.USER_NOT_FOUND); + } + + boolean applyCursor = StringUtils.hasText(cursor); + LocalDateTime cursorCreatedAt = CURSOR_DUMMY_TIME; + long cursorProblemSolverId = 0L; + if (applyCursor) { + UserSolverCursorCodec.Decoded decoded = userSolverCursorCodec.decode(cursor.trim()); + cursorCreatedAt = decoded.createdAt(); + cursorProblemSolverId = decoded.problemSolverId(); + } + + List rows = + userMySolverRepository.findMySolvers( + uid, applyCursor, cursorCreatedAt, cursorProblemSolverId, pageSize + 1); + + boolean hasNext = rows.size() > pageSize; + List pageRows = hasNext ? rows.subList(0, pageSize) : rows; + + List pins = + pageRows.stream() + .map( + r -> + new UserMySolverItemResDTO( + r.getPinId(), + r.getPinTitle(), + r.getPinDetailAddress(), + r.getIssuePinState(), + r.getCreatedAt())) + .toList(); + + String nextCursor = null; + if (hasNext && !pageRows.isEmpty()) { + UserMySolverRow last = pageRows.get(pageRows.size() - 1); + nextCursor = userSolverCursorCodec.encode(last.getCreatedAt(), last.getProblemSolverId()); + } + + return new UserMySolversResDTO(pins, new UserMyPinPageInfoResDTO(hasNext, nextCursor)); + } + + private int resolveSize(Integer size) { + int s = size == null ? SIZE_DEFAULT : size; + if (s < SIZE_MIN || s > SIZE_MAX) { + throw UserException.of(UserErrorCode.USER_SOLVER_400_1); + } + return s; + } +} diff --git a/src/main/java/issueissyu/backend/domain/user/support/UserSolverCursorCodec.java b/src/main/java/issueissyu/backend/domain/user/support/UserSolverCursorCodec.java new file mode 100644 index 00000000..fa50a97c --- /dev/null +++ b/src/main/java/issueissyu/backend/domain/user/support/UserSolverCursorCodec.java @@ -0,0 +1,55 @@ +package issueissyu.backend.domain.user.support; + +import issueissyu.backend.domain.user.exception.UserException; +import issueissyu.backend.domain.user.exception.code.UserErrorCode; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.temporal.ChronoField; +import java.util.Base64; +import org.springframework.stereotype.Component; + +@Component +public class UserSolverCursorCodec { + + private static final DateTimeFormatter CURSOR_TIME = + new DateTimeFormatterBuilder() + .appendPattern("yyyy-MM-dd HH:mm:ss") + .appendFraction(ChronoField.NANO_OF_SECOND, 6, 9, true) + .toFormatter(); + + public record Decoded(LocalDateTime createdAt, long problemSolverId) {} + + public String encode(LocalDateTime createdAt, long problemSolverId) { + String raw = createdAt.format(CURSOR_TIME) + ":" + problemSolverId; + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(raw.getBytes(StandardCharsets.UTF_8)); + } + + public Decoded decode(String cursor) { + if (cursor == null || cursor.isBlank()) { + throw UserException.of(UserErrorCode.USER_SOLVER_400_2); + } + try { + String raw = new String(Base64.getUrlDecoder().decode(cursor.trim()), StandardCharsets.UTF_8); + int delim = raw.lastIndexOf(':'); + if (delim < 0 || delim == raw.length() - 1) { + throw UserException.of(UserErrorCode.USER_SOLVER_400_2); + } + String createdAtRaw = raw.substring(0, delim); + String problemSolverIdRaw = raw.substring(delim + 1); + long problemSolverId = Long.parseLong(problemSolverIdRaw); + if (problemSolverId <= 0) { + throw UserException.of(UserErrorCode.USER_SOLVER_400_2); + } + LocalDateTime createdAt = LocalDateTime.parse(createdAtRaw, CURSOR_TIME); + return new Decoded(createdAt, problemSolverId); + } catch (UserException e) { + throw e; + } catch (Exception e) { + throw UserException.of(UserErrorCode.USER_SOLVER_400_2); + } + } +}