forked from wangziqi/ruoyi-vue-pro
fix(education): fence exam reminder delivery claims
This commit is contained in:
@@ -25,5 +25,9 @@ public class ExamReminderDO extends TenantBaseDO {
|
||||
private Long notifyMessageId;
|
||||
private LocalDateTime sentTime;
|
||||
private String lastError;
|
||||
private String claimToken;
|
||||
private LocalDateTime claimExpiresAt;
|
||||
private Integer attemptCount;
|
||||
private Integer maxAttempts;
|
||||
private Integer version;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,7 @@ import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.ExamReminderDO;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
@@ -36,28 +35,44 @@ public interface ExamReminderMapper extends BaseMapperX<ExamReminderDO> {
|
||||
@Select("""
|
||||
WITH due AS (
|
||||
SELECT id FROM education_exam_reminder
|
||||
WHERE deleted = false AND status IN ('SCHEDULED', 'FAILED') AND remind_time <= #{now}
|
||||
WHERE deleted = false
|
||||
AND ((status IN ('SCHEDULED', 'FAILED') AND remind_time <= #{now})
|
||||
OR (status = 'SENDING' AND claim_expires_at <= #{now}))
|
||||
AND attempt_count < max_attempts
|
||||
ORDER BY remind_time, id FOR UPDATE SKIP LOCKED LIMIT #{limit}
|
||||
)
|
||||
UPDATE education_exam_reminder r SET status = 'SENDING', version = version + 1,
|
||||
update_time = CURRENT_TIMESTAMP, last_error = NULL
|
||||
UPDATE education_exam_reminder r SET status = 'SENDING', claim_token = #{claimToken},
|
||||
claim_expires_at = #{claimExpiresAt}, attempt_count = attempt_count + 1,
|
||||
version = version + 1, update_time = CURRENT_TIMESTAMP, last_error = NULL
|
||||
FROM due WHERE r.id = due.id
|
||||
RETURNING r.*
|
||||
""")
|
||||
List<ExamReminderDO> claimDue(LocalDateTime now, int limit);
|
||||
List<ExamReminderDO> claimDue(@Param("now") LocalDateTime now, @Param("claimToken") String claimToken,
|
||||
@Param("claimExpiresAt") LocalDateTime claimExpiresAt, @Param("limit") int limit);
|
||||
|
||||
@TenantIgnore
|
||||
default int markSent(Long id, Long notifyMessageId) {
|
||||
default int markSent(Long id, String claimToken, Long notifyMessageId) {
|
||||
return update(null, new LambdaUpdateWrapper<ExamReminderDO>().eq(ExamReminderDO::getId, id)
|
||||
.eq(ExamReminderDO::getStatus, "SENDING").set(ExamReminderDO::getStatus, "SENT")
|
||||
.set(ExamReminderDO::getNotifyMessageId, notifyMessageId)
|
||||
.set(ExamReminderDO::getSentTime, LocalDateTime.now()));
|
||||
.eq(ExamReminderDO::getStatus, "SENDING").eq(ExamReminderDO::getClaimToken, claimToken)
|
||||
.gt(ExamReminderDO::getClaimExpiresAt, LocalDateTime.now()).set(ExamReminderDO::getStatus, "SENT")
|
||||
.set(ExamReminderDO::getNotifyMessageId, notifyMessageId).set(ExamReminderDO::getSentTime, LocalDateTime.now())
|
||||
.set(ExamReminderDO::getClaimToken, null).set(ExamReminderDO::getClaimExpiresAt, null));
|
||||
}
|
||||
|
||||
@TenantIgnore
|
||||
default int markFailed(Long id, String error) {
|
||||
default int markFailed(Long id, String claimToken, String error) {
|
||||
return update(null, new LambdaUpdateWrapper<ExamReminderDO>().eq(ExamReminderDO::getId, id)
|
||||
.eq(ExamReminderDO::getStatus, "SENDING").set(ExamReminderDO::getStatus, "FAILED")
|
||||
.set(ExamReminderDO::getLastError, error));
|
||||
.eq(ExamReminderDO::getStatus, "SENDING").eq(ExamReminderDO::getClaimToken, claimToken)
|
||||
.set(ExamReminderDO::getStatus, "FAILED").set(ExamReminderDO::getLastError, error)
|
||||
.set(ExamReminderDO::getClaimToken, null).set(ExamReminderDO::getClaimExpiresAt, null));
|
||||
}
|
||||
|
||||
@TenantIgnore
|
||||
@Update("""
|
||||
UPDATE education_exam_reminder SET status='FAILED', last_error='ATTEMPTS_EXHAUSTED',
|
||||
claim_token=NULL, claim_expires_at=NULL, update_time=CURRENT_TIMESTAMP
|
||||
WHERE deleted=false AND status='SENDING' AND attempt_count >= max_attempts
|
||||
AND claim_expires_at <= #{now}
|
||||
""")
|
||||
int failExhausted(@Param("now") LocalDateTime now);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST;
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
@@ -64,7 +65,11 @@ public class ExamReminderServiceImpl implements ExamReminderService {
|
||||
@Override
|
||||
public int sendDue(int limit) {
|
||||
int sent = 0;
|
||||
for (ExamReminderDO row : mapper.claimDue(LocalDateTime.now(), Math.max(1, Math.min(limit, 100)))) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
mapper.failExhausted(now);
|
||||
String claimToken = UUID.randomUUID().toString();
|
||||
for (ExamReminderDO row : mapper.claimDue(now, claimToken, now.plusMinutes(2),
|
||||
Math.max(1, Math.min(limit, 100)))) {
|
||||
try {
|
||||
TenantContextHolder.setTenantId(row.getTenantId());
|
||||
NotifySendSingleToUserReqDTO dto = new NotifySendSingleToUserReqDTO();
|
||||
@@ -72,9 +77,12 @@ public class ExamReminderServiceImpl implements ExamReminderService {
|
||||
dto.setTemplateParams(Map.of("title", row.getTitle(), "examTime", DISPLAY_TIME.format(row.getExamTime())));
|
||||
Long messageId = notifyApi.sendSingleMessageToMember(dto);
|
||||
if (messageId == null) throw new IllegalStateException("notify template disabled");
|
||||
mapper.markSent(row.getId(), messageId); sent++;
|
||||
if (mapper.markSent(row.getId(), claimToken, messageId) != 1) {
|
||||
throw new IllegalStateException("reminder claim lease lost");
|
||||
}
|
||||
sent++;
|
||||
} catch (Exception ex) {
|
||||
mapper.markFailed(row.getId(), bounded(ex.getMessage()));
|
||||
mapper.markFailed(row.getId(), claimToken, bounded(ex.getMessage()));
|
||||
} finally {
|
||||
TenantContextHolder.clear();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Add recoverable, token-fenced leases to exam reminder delivery.
|
||||
|
||||
ALTER TABLE education_exam_reminder
|
||||
ADD COLUMN claim_token VARCHAR(64),
|
||||
ADD COLUMN claim_expires_at TIMESTAMP,
|
||||
ADD COLUMN attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN max_attempts INTEGER NOT NULL DEFAULT 5;
|
||||
|
||||
ALTER TABLE education_exam_reminder
|
||||
ADD CONSTRAINT ck_education_exam_reminder_attempts
|
||||
CHECK (attempt_count >= 0 AND max_attempts > 0 AND attempt_count <= max_attempts),
|
||||
ADD CONSTRAINT ck_education_exam_reminder_claim
|
||||
CHECK ((claim_token IS NULL AND claim_expires_at IS NULL)
|
||||
OR (claim_token IS NOT NULL AND claim_expires_at IS NOT NULL AND status = 'SENDING'));
|
||||
|
||||
CREATE INDEX idx_education_exam_reminder_recovery
|
||||
ON education_exam_reminder (claim_expires_at, remind_time, id)
|
||||
WHERE deleted = false AND status = 'SENDING';
|
||||
|
||||
COMMENT ON COLUMN education_exam_reminder.claim_token IS 'Fencing token required by reminder terminal writes';
|
||||
COMMENT ON COLUMN education_exam_reminder.claim_expires_at IS 'Expired SENDING claims may be reclaimed after worker interruption';
|
||||
Reference in New Issue
Block a user