fix(education): fence exam reminder delivery claims

This commit is contained in:
2026-07-31 16:26:39 +08:00
parent bd956bcb35
commit 26f9bcf696
4 changed files with 64 additions and 16 deletions

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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();
}

View File

@@ -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';