From 26f9bcf6967f662b462bfd46c55b0b730ed9a549 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Fri, 31 Jul 2026 16:26:39 +0800 Subject: [PATCH] fix(education): fence exam reminder delivery claims --- .../dal/dataobject/ExamReminderDO.java | 4 ++ .../dal/mysql/ExamReminderMapper.java | 41 +++++++++++++------ .../reminder/ExamReminderServiceImpl.java | 14 +++++-- .../V4200__add_exam_reminder_claim_lease.sql | 21 ++++++++++ 4 files changed, 64 insertions(+), 16 deletions(-) create mode 100644 yudao-module-education/src/main/resources/db/migration/education/V4200__add_exam_reminder_claim_lease.sql diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/ExamReminderDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/ExamReminderDO.java index 054dbf24..08beeb91 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/ExamReminderDO.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/ExamReminderDO.java @@ -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; } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/ExamReminderMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/ExamReminderMapper.java index 3bf48a16..cc8ce61f 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/ExamReminderMapper.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/ExamReminderMapper.java @@ -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 { @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 claimDue(LocalDateTime now, int limit); + List 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().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().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); } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/reminder/ExamReminderServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/reminder/ExamReminderServiceImpl.java index eaa6e4e8..c0e5e870 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/reminder/ExamReminderServiceImpl.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/reminder/ExamReminderServiceImpl.java @@ -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(); } diff --git a/yudao-module-education/src/main/resources/db/migration/education/V4200__add_exam_reminder_claim_lease.sql b/yudao-module-education/src/main/resources/db/migration/education/V4200__add_exam_reminder_claim_lease.sql new file mode 100644 index 00000000..bd7f1a85 --- /dev/null +++ b/yudao-module-education/src/main/resources/db/migration/education/V4200__add_exam_reminder_claim_lease.sql @@ -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';