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
@@ -0,0 +1,26 @@
package com.zimdugo.push.application;

import com.zimdugo.push.domain.PushDeviceReader;
import com.zimdugo.push.domain.PushReminderStore;
import java.time.Clock;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional
public class PushReminderDeleteService {

private final PushDeviceReader pushDeviceReader;
private final PushReminderStore pushReminderStore;
private final Clock clock;

public void delete(String deviceTokenHash, Long reminderId) {
// 기기 소유권이 확인된 요청만 삭제 상태를 바꿔 다른 브라우저의 리마인더를 보호한다.
pushDeviceReader.findIdByTokenHash(deviceTokenHash)
.ifPresent(deviceId -> pushReminderStore.cancelActiveByIdAndDeviceId(
reminderId, deviceId, clock.instant()
));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,7 @@ public class PushReminderDispatchProperties {

@Min(1)
private int maximumDeliveryAttempts;

@Min(1)
private int dispatchClaimSeconds;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.zimdugo.push.domain;

public enum PushReminderJobStatus {
PENDING,
DISPATCHING,
SENT,
DISCARDED,
CANCELLED
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ public interface PushReminderStore {

long countActiveByDeviceId(Long deviceId, Instant now);

void cancelActiveByIdAndDeviceId(Long reminderId, Long deviceId, Instant now);

Long save(PushReminderSaveCommand command);

void schedule(Long reminderId, PushNotificationType type, Instant fireAt);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.zimdugo.core.response.SuccessCode;
import com.zimdugo.push.application.PushDeviceTokenHasher;
import com.zimdugo.push.application.PushReminderCreateService;
import com.zimdugo.push.application.PushReminderDeleteService;
import com.zimdugo.push.application.PushReminderQueryService;
import com.zimdugo.push.application.PushReminderResult;
import com.zimdugo.push.entrypoint.dto.PushReminderCreateRequest;
Expand All @@ -18,10 +19,11 @@
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1")
public class PushReminderController implements PushReminderCreateApi, PushReminderQueryApi {
public class PushReminderController implements PushReminderCreateApi, PushReminderDeleteApi, PushReminderQueryApi {

private final PushReminderCreateService pushReminderCreateService;
private final PushReminderQueryService pushReminderQueryService;
private final PushReminderDeleteService pushReminderDeleteService;
private final PushDeviceTokenHasher pushDeviceTokenHasher;

@Override
Expand All @@ -46,6 +48,15 @@ public ResponseEntity<RestResponse<List<PushReminderResponse>>> getActiveReminde
return ResponseEntity.ok(RestResponse.of(SuccessCode.OK, responses));
}

@Override
public ResponseEntity<RestResponse<Void>> deleteReminder(
Long reminderId,
@CookieValue(name = "deviceToken", required = false) String deviceToken
) {
pushReminderDeleteService.delete(hashDeviceToken(deviceToken), reminderId);
return ResponseEntity.ok(RestResponse.ok(SuccessCode.OK));
}

private String hashDeviceToken(String deviceToken) {
// 원문 토큰을 저장소 계층으로 넘기지 않아 기기 식별값의 노출 범위를 제한한다.
return pushDeviceTokenHasher.hash(deviceToken == null ? "" : deviceToken);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.zimdugo.push.entrypoint;

import com.zimdugo.core.response.RestResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.security.SecurityRequirements;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.Positive;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;

@Tag(name = "Push", description = "웹 푸시 API")
public interface PushReminderDeleteApi {

@Operation(summary = "현재 기기의 리마인더 삭제")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "삭제 성공 또는 삭제할 리마인더 없음")
})
@SecurityRequirements
@DeleteMapping("/push/reminders/{reminderId}")
ResponseEntity<RestResponse<Void>> deleteReminder(@PathVariable @Positive Long reminderId, String deviceToken);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.zimdugo.push.infrastructure.persistence;

import com.zimdugo.push.domain.PushNotificationType;
import com.zimdugo.push.domain.PushReminderJobStatus;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
Expand Down Expand Up @@ -43,26 +44,30 @@ public class PushReminderJobEntity {
@Column(name = "attempt_count", nullable = false)
private int attemptCount;

@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private PushReminderJobStatus status;

public PushReminderJobEntity(Long reminderId, PushNotificationType type, Instant fireAt) {
this.reminderId = reminderId;
this.type = type;
this.fireAt = fireAt;
this.nextAttemptAt = fireAt;
this.status = PushReminderJobStatus.PENDING;
}

public void recordAttempt() {
this.attemptCount++;
}

public void markProcessed(Instant processedAt) {
public void markSent(Instant processedAt) {
this.status = PushReminderJobStatus.SENT;
this.processedAt = processedAt;
}

public void discard(Instant processedAt) {
this.status = PushReminderJobStatus.DISCARDED;
this.processedAt = processedAt;
}

public void retryAt(Instant nextAttemptAt) {
this.status = PushReminderJobStatus.PENDING;
this.nextAttemptAt = nextAttemptAt;
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,70 @@
package com.zimdugo.push.infrastructure.persistence;

import com.zimdugo.push.domain.PushReminderJobStatus;
import java.time.Instant;
import java.util.List;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.data.domain.Pageable;

public interface PushReminderJobRepository extends JpaRepository<PushReminderJobEntity, Long> {

List<PushReminderJobEntity> findTop100ByProcessedAtIsNullAndNextAttemptAtLessThanEqualOrderByFireAtAsc(Instant now);
@Query("""
select job
from PushReminderJobEntity job
where job.status = :status
and job.nextAttemptAt <= :now
order by job.fireAt asc
""")
List<PushReminderJobEntity> findDueJobs(
@Param("status") PushReminderJobStatus status,
@Param("now") Instant now,
Pageable pageable
);

@Modifying
@Query("""
update PushReminderJobEntity job
set job.status = :dispatchingStatus,
job.nextAttemptAt = :leaseExpiresAt,
job.attemptCount = job.attemptCount + 1
where job.id = :jobId
and job.status = :pendingStatus
""")
int claimPendingById(
@Param("jobId") Long jobId,
@Param("pendingStatus") PushReminderJobStatus pendingStatus,
@Param("dispatchingStatus") PushReminderJobStatus dispatchingStatus,
@Param("leaseExpiresAt") Instant leaseExpiresAt
);

@Modifying
@Query("""
update PushReminderJobEntity job
set job.status = :pendingStatus
where job.status = :dispatchingStatus
and job.nextAttemptAt <= :now
""")
void requeueExpiredDispatches(
@Param("dispatchingStatus") PushReminderJobStatus dispatchingStatus,
@Param("pendingStatus") PushReminderJobStatus pendingStatus,
@Param("now") Instant now
);

@Modifying
@Query("""
update PushReminderJobEntity job
set job.status = :cancelledStatus,
job.processedAt = :processedAt
where job.reminderId = :reminderId
and job.status = :pendingStatus
""")
void cancelPendingByReminderId(
@Param("reminderId") Long reminderId,
@Param("pendingStatus") PushReminderJobStatus pendingStatus,
@Param("cancelledStatus") PushReminderJobStatus cancelledStatus,
@Param("processedAt") Instant processedAt
);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.zimdugo.push.infrastructure.persistence;

import com.zimdugo.push.domain.PushNotificationType;
import com.zimdugo.push.domain.PushReminderJobStatus;
import com.zimdugo.push.domain.PushReminderReader;
import com.zimdugo.push.domain.PushReminderSaveCommand;
import com.zimdugo.push.domain.PushReminderSummary;
Expand Down Expand Up @@ -45,6 +46,19 @@ public long countActiveByDeviceId(Long deviceId, Instant now) {
return pushReminderRepository.countActiveByDeviceId(deviceId, now);
}

@Override
public void cancelActiveByIdAndDeviceId(Long reminderId, Long deviceId, Instant now) {
int deletedCount = pushReminderRepository.softDeleteActiveByIdAndDeviceId(
reminderId, deviceId, now, PushReminderStatus.ACTIVE, now
);
if (deletedCount > 0) {
// 선점 전 작업만 취소해 삭제 이후 발송은 막고, 이미 시작된 외부 전송은 되돌리지 않는다.
pushReminderJobRepository.cancelPendingByReminderId(
reminderId, PushReminderJobStatus.PENDING, PushReminderJobStatus.CANCELLED, now
);
}
}

@Override
public Long save(PushReminderSaveCommand command) {
return pushReminderRepository.save(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,24 @@ long countActiveByDeviceId(
@Param("now") Instant now
);

@Modifying
@Query("""
update PushReminderEntity reminder
set reminder.deletedAt = :deletedAt
where reminder.id = :reminderId
and reminder.deviceId = :deviceId
and reminder.status = :activeStatus
and reminder.deletedAt is null
and reminder.endAt > :now
""")
int softDeleteActiveByIdAndDeviceId(
@Param("reminderId") Long reminderId,
@Param("deviceId") Long deviceId,
@Param("deletedAt") Instant deletedAt,
@Param("activeStatus") PushReminderStatus activeStatus,
@Param("now") Instant now
);

@Modifying
@Query("""
update PushReminderEntity reminder
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.zimdugo.push.config.PushReminderDispatchProperties;
import com.zimdugo.push.domain.PushLockerNameReader;
import com.zimdugo.push.domain.PushNotificationType;
import com.zimdugo.push.domain.PushReminderJobStatus;
import com.zimdugo.push.domain.PushReminderStatus;
import com.zimdugo.push.domain.PushSubscription;
import com.zimdugo.push.domain.WebPushSendResult;
Expand Down Expand Up @@ -32,11 +33,10 @@ public class PushReminderDeliveryProcessor {
@Transactional
public DeliveryCandidate prepare(Long jobId) {
Instant now = clock.instant();
PushReminderJobEntity job = pushReminderJobRepository.findById(jobId).orElse(null);
if (job == null || job.getProcessedAt() != null) {
PushReminderJobEntity job = claim(jobId, now);
if (job == null) {
return null;
}
job.recordAttempt();
PushReminderEntity reminder = pushReminderRepository.findById(job.getReminderId()).orElse(null);
if (reminder == null || reminder.getStatus() != PushReminderStatus.ACTIVE || reminder.getDeletedAt() != null) {
discard(job, reminder, now);
Expand All @@ -55,6 +55,19 @@ public DeliveryCandidate prepare(Long jobId) {
return candidate(job, reminder, subscription);
}

private PushReminderJobEntity claim(Long jobId, Instant now) {
int claimedCount = pushReminderJobRepository.claimPendingById(
jobId,
PushReminderJobStatus.PENDING,
PushReminderJobStatus.DISPATCHING,
now.plusSeconds(properties.getDispatchClaimSeconds())
);
if (claimedCount == 0) {
return null;
}
return pushReminderJobRepository.findById(jobId).orElse(null);
}

private DeliveryCandidate candidate(
PushReminderJobEntity job,
PushReminderEntity reminder,
Expand All @@ -64,6 +77,7 @@ private DeliveryCandidate candidate(
job.getId(),
reminder.getId(),
job.getType(),
job.getAttemptCount(),
reminder.getLockerId(),
subscription.getId(),
subscription.getEndpoint(),
Expand All @@ -77,7 +91,9 @@ private DeliveryCandidate candidate(
@Transactional
public void complete(DeliveryCandidate candidate, WebPushSendResult result) {
PushReminderJobEntity job = pushReminderJobRepository.findById(candidate.jobId()).orElse(null);
if (job == null || job.getProcessedAt() != null) {
if (job == null ||
job.getStatus() != PushReminderJobStatus.DISPATCHING ||
job.getAttemptCount() != candidate.attemptCount()) {
return;
}
Instant now = clock.instant();
Expand All @@ -90,8 +106,10 @@ public void complete(DeliveryCandidate candidate, WebPushSendResult result) {
pushSubscriptionRepository.findById(candidate.subscriptionId())
.filter(subscription -> subscription.getEndpoint().equals(candidate.endpoint()))
.ifPresent(pushSubscriptionRepository::delete);
discard(job, reminder, now);
return;
}
job.markProcessed(now);
job.markSent(now);
completeReminderAtEnd(job, reminder);
}

Expand All @@ -118,6 +136,7 @@ public record DeliveryCandidate(
Long jobId,
Long reminderId,
PushNotificationType type,
int attemptCount,
Long lockerId,
Long subscriptionId,
String endpoint,
Expand Down
Loading
Loading