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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ public ApiResponse<LikeAlarmSendResDTO> 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} 로 이동합니다.
Expand All @@ -101,7 +101,7 @@ public ApiResponse<EventAlarmSendResDTO> sendEventAlarm(@AuthenticationPrincipal
description =
"""
동네 인증 지역의 가게(STORE) 핀에 대한 푸시 알람을 요청자에게 전송합니다.
event_start_time 이 현재 시각 ±12시간 이내인 핀만 대상이며, 제목·본문은 서버에서 고정값으로 생성합니다.
event_start_time 이 현재 시각 24시간 이내인 핀만 대상이며, 제목·본문은 서버에서 고정값으로 생성합니다.
user.store_alarm_active 가 false 이면 STORE_ALARM_403 을 반환합니다.
알람 클릭 시 GET /api/communities/{communityId} 로 이동합니다.
""")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,10 +183,9 @@ private void dispatchFcmBatch(List<FcmNotificationPayload> 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};
}
Comment thread
taerimiiii marked this conversation as resolved.

private User findUser(String uid) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -33,6 +35,7 @@
public class UserController {

private final UserPinQueryService userPinQueryService;
private final UserSolverQueryService userSolverQueryService;
private final UserAlarmStateQueryService userAlarmStateQueryService;
private final UserCommandService userCommandService;

Expand Down Expand Up @@ -87,4 +90,17 @@ public ApiResponse<UserMyPinsResDTO> 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<UserMySolversResDTO> 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));
}
}
Original file line number Diff line number Diff line change
@@ -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) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package issueissyu.backend.domain.user.dto.res;

import java.util.List;

public record UserMySolversResDTO(List<UserMySolverItemResDTO> pins, UserMyPinPageInfoResDTO pageInfo) {}
Original file line number Diff line number Diff line change
Expand Up @@ -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", "존재하지 않는 알람 설정입니다.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", "이벤트 알람 비/활성화에 성공했습니다."),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ProblemSolver, Long> {

@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
)
)
Comment on lines +33 to +40

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

PostgreSQL은 다중 열 비교(Row-value comparison)를 지원합니다. 기존의 복잡한 OR 조건문 대신 행 생성자(Row Constructor) 비교 연산자 (ps.created_at, ps.problem_solver_id) < (CAST(:cursorCreatedAt AS timestamp), :cursorProblemSolverId)를 사용하면 쿼리가 훨씬 간결해지고 가독성이 향상되며, 다중 열 인덱스(Multi-column index)를 활용한 최적화에도 유리합니다.

Suggested change
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
)
)
AND (
NOT CAST(:applyCursor AS boolean)
OR (ps.created_at, ps.problem_solver_id) < (CAST(:cursorCreatedAt AS timestamp), :cursorProblemSolverId)
)

ORDER BY ps.created_at DESC, ps.problem_solver_id DESC
LIMIT :limit
""",
nativeQuery = true)
List<UserMySolverRow> findMySolvers(
@Param("uid") String uid,
@Param("applyCursor") boolean applyCursor,
@Param("cursorCreatedAt") LocalDateTime cursorCreatedAt,
@Param("cursorProblemSolverId") long cursorProblemSolverId,
@Param("limit") int limit);
}
Original file line number Diff line number Diff line change
@@ -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();
}
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

사용자의 존재 여부만 확인하는 로직에서 userRepository.findById(uid)를 사용하면 해당 User 엔티티 전체와 연관된 관계들을 영속성 컨텍스트에 로드하게 되므로 불필요한 메모리 및 쿼리 오버헤드가 발생할 수 있습니다.\n\n단순히 존재 여부만 검증할 때는 existsById(uid)를 사용하는 것이 성능상 훨씬 효율적입니다.

Suggested change
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<UserMySolverRow> rows =
userMySolverRepository.findMySolvers(
uid, applyCursor, cursorCreatedAt, cursorProblemSolverId, pageSize + 1);

boolean hasNext = rows.size() > pageSize;
List<UserMySolverRow> pageRows = hasNext ? rows.subList(0, pageSize) : rows;

List<UserMySolverItemResDTO> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Loading