This commit is contained in:
2026-08-14 16:06:27 +08:00
parent 87eb1aa72f
commit 87e919f3c4
54 changed files with 984 additions and 175 deletions
@@ -127,7 +127,7 @@ public class InternalTenantController {
@RequestParam(defaultValue = "500") @Min(1) @Max(500) int limit,
@RequestParam(required = false) String keyword,
@RequestParam(required = false)
@Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED") String status) {
@Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED|ARCHIVED") String status) {
verify(token);
return ApiResponse.success(tenantService.exportMerchants(cursor, limit, keyword, status));
}
@@ -62,7 +62,7 @@ public class PlatformTenantController {
@RequestParam(defaultValue = "20") @Min(1) @Max(200) long size,
@RequestParam(required = false) String keyword,
@RequestParam(required = false)
@Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED") String status,
@Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED|ARCHIVED") String status,
@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(service.pageMerchants(page, size, keyword, status));
@@ -31,6 +31,8 @@ public class PlatformNotificationController {
public ApiResponse<TemplateResponse> status(@PathVariable Long id,@Valid@RequestBody ChangeTemplateStatusRequest request,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.changeTemplateStatus(id,request,user.membershipId()));}
@GetMapping("/notification-tasks") @PreAuthorize("hasAuthority('platform:notification:task:list')")
public ApiResponse<PageResult<TaskListItem>> tasks(@RequestParam(defaultValue="1")@Min(1)long page,@RequestParam(defaultValue="20")@Min(1)@Max(200)long size,@RequestParam(required=false)String taskNo,@RequestParam(required=false)String category,@RequestParam(required=false)String title,@RequestParam(required=false)@Pattern(regexp="CREATED|SCHEDULED|DISPATCHING|RETRYING|SENT|DEAD|CANCELLED")String status,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.tasks(page,size,taskNo,category,title,status));}
@GetMapping("/notification-tasks/{id:\\d+}") @PreAuthorize("hasAuthority('platform:notification:task:list')")
public ApiResponse<TaskDetail> taskDetail(@PathVariable Long id,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.taskDetail(id));}
@PutMapping("/notification-tasks/{id:\\d+}/cancel") @PreAuthorize("hasAuthority('platform:notification:task:cancel')") public ApiResponse<TaskListItem> cancel(@PathVariable Long id,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.cancel(id));}
@PutMapping("/notification-tasks/{id:\\d+}/retry") @PreAuthorize("hasAuthority('platform:notification:task:retry')") public ApiResponse<TaskListItem> retry(@PathVariable Long id,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.retry(id));}
private void platform(AuthenticatedUser user){TenantGuard.tenant(user,"PLATFORM");}
@@ -28,10 +28,10 @@ public final class NotificationDtos {
@Size(max = 128) String sourceEventId,
@JsonIgnore @Size(max = 128) String sourceEventType) { }
public record NotificationSummary(Long id, String category, String title, String status,
public record NotificationSummary(Long id, String category, String title, String summary, String status,
LocalDateTime sentAt, LocalDateTime readAt) { }
public record NotificationDetail(Long id, String category, String title, String body,
public record NotificationDetail(Long id, String category, String title, String summary, String body,
String status, String sourceType, String sourceId,
LocalDateTime sentAt, LocalDateTime readAt) { }
@@ -69,4 +69,12 @@ public final class NotificationDtos {
public record TaskListItem(Long id, String taskNo, String category, String title, String status,
int recipientCount, LocalDateTime scheduledAt, LocalDateTime sentAt,
boolean duplicated) { }
public record TaskRecipientItem(Long id, String displayName, String appCode, String status,
String failureReason, LocalDateTime deliveredAt) { }
public record TaskDetail(Long id, String taskNo, String category, String title, String status,
int recipientCount, int attemptCount, LocalDateTime scheduledAt,
LocalDateTime sentAt, String lastError,
List<TaskRecipientItem> recipients) { }
}
@@ -6,6 +6,35 @@ import org.apache.ibatis.annotations.Update;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDateTime;
@Mapper public interface NotificationTaskMapper extends BaseMapper<NotificationTask> {
@Update("""
UPDATE iam_notification_task
SET status = 'DISPATCHING',
attempt_count = attempt_count + 1,
updated_at = #{now}
WHERE id = #{taskId}
AND status = 'SCHEDULED'
AND scheduled_at <= #{now}
AND attempt_count < #{maxAttempts}
""")
int claimScheduled(@Param("taskId") Long taskId,
@Param("maxAttempts") int maxAttempts,
@Param("now") LocalDateTime now);
@Update("""
UPDATE iam_notification_task
SET status = 'DISPATCHING',
attempt_count = attempt_count + 1,
updated_at = #{now}
WHERE id = #{taskId}
AND status = 'RETRYING'
AND updated_at <= #{dueBefore}
AND attempt_count < #{maxAttempts}
""")
int claimRetrying(@Param("taskId") Long taskId,
@Param("maxAttempts") int maxAttempts,
@Param("dueBefore") LocalDateTime dueBefore,
@Param("now") LocalDateTime now);
@Update("""
UPDATE iam_notification_task task
SET task.status = 'DEAD',
@@ -27,15 +56,36 @@ import java.time.LocalDateTime;
int deadTimedOutDispatchingWithDeliveryEvidence(@Param("staleBefore") LocalDateTime staleBefore,
@Param("now") LocalDateTime now);
@Update("""
UPDATE iam_notification_task task
SET task.status = 'DEAD',
task.last_error = 'DISPATCH_TIMEOUT_ATTEMPT_LIMIT',
task.updated_at = #{now}
WHERE task.status = 'DISPATCHING'
AND task.updated_at <= #{staleBefore}
AND task.sent_at IS NULL
AND task.attempt_count >= #{maxAttempts}
AND NOT EXISTS (
SELECT 1 FROM iam_notification_recipient recipient
WHERE recipient.task_id = task.id AND recipient.status = 'DELIVERED'
)
AND NOT EXISTS (
SELECT 1 FROM iam_notification_inbox inbox WHERE inbox.task_id = task.id
)
""")
int deadTimedOutDispatchingAtAttemptLimit(@Param("staleBefore") LocalDateTime staleBefore,
@Param("now") LocalDateTime now,
@Param("maxAttempts") int maxAttempts);
@Update("""
UPDATE iam_notification_task task
SET task.status = 'RETRYING',
task.attempt_count = task.attempt_count + 1,
task.last_error = 'DISPATCH_TIMEOUT_RECOVERED',
task.updated_at = #{now}
WHERE task.status = 'DISPATCHING'
AND task.updated_at <= #{staleBefore}
AND task.sent_at IS NULL
AND task.attempt_count < #{maxAttempts}
AND NOT EXISTS (
SELECT 1 FROM iam_notification_recipient recipient
WHERE recipient.task_id = task.id AND recipient.status = 'DELIVERED'
@@ -45,5 +95,19 @@ import java.time.LocalDateTime;
)
""")
int retryTimedOutDispatchingWithoutDeliveryEvidence(@Param("staleBefore") LocalDateTime staleBefore,
@Param("now") LocalDateTime now);
@Param("now") LocalDateTime now,
@Param("maxAttempts") int maxAttempts);
@Update("""
UPDATE iam_notification_task
SET status = CASE WHEN attempt_count >= #{maxAttempts} THEN 'DEAD' ELSE 'RETRYING' END,
last_error = #{error},
updated_at = #{now}
WHERE id = #{taskId}
AND status = 'DISPATCHING'
""")
int failDispatch(@Param("taskId") Long taskId,
@Param("maxAttempts") int maxAttempts,
@Param("error") String error,
@Param("now") LocalDateTime now);
}
@@ -49,21 +49,43 @@ public class NotificationRepository {
public void insertRecipient(NotificationRecipient value) { recipients.insert(value); }
public List<NotificationRecipient> recipients(Long taskId) { return recipients.selectList(new LambdaQueryWrapper<NotificationRecipient>().eq(NotificationRecipient::getTaskId,taskId)); }
public void saveRecipient(NotificationRecipient value) { recipients.updateById(value); }
public List<NotificationTask> dueTasks() { return tasks.selectList(new LambdaQueryWrapper<NotificationTask>()
.eq(NotificationTask::getStatus,"SCHEDULED").le(NotificationTask::getScheduledAt,java.time.LocalDateTime.now()).orderByAsc(NotificationTask::getScheduledAt).last("LIMIT 100")); }
public boolean claimScheduled(Long taskId) { return tasks.update(null,new LambdaUpdateWrapper<NotificationTask>()
.eq(NotificationTask::getId,taskId).eq(NotificationTask::getStatus,"SCHEDULED")
.set(NotificationTask::getStatus,"DISPATCHING").set(NotificationTask::getUpdatedAt,java.time.LocalDateTime.now()))==1; }
public int recoverTimedOutDispatching(java.time.LocalDateTime staleBefore,java.time.LocalDateTime now) {
int dead = tasks.deadTimedOutDispatchingWithDeliveryEvidence(staleBefore,now);
return dead + tasks.retryTimedOutDispatchingWithoutDeliveryEvidence(staleBefore,now);
public List<NotificationTask> dueTasks(java.time.LocalDateTime now) { return tasks.selectList(new LambdaQueryWrapper<NotificationTask>()
.eq(NotificationTask::getStatus,"SCHEDULED").le(NotificationTask::getScheduledAt,now).orderByAsc(NotificationTask::getScheduledAt).last("LIMIT 100")); }
public boolean claimScheduled(Long taskId,int maxAttempts,java.time.LocalDateTime now) {
return tasks.claimScheduled(taskId,maxAttempts,now)==1;
}
public List<NotificationTask> retryingTasks() { return tasks.selectList(new LambdaQueryWrapper<NotificationTask>()
.eq(NotificationTask::getStatus,"RETRYING").orderByAsc(NotificationTask::getUpdatedAt).last("LIMIT 100")); }
public boolean claimRetrying(Long taskId) { return tasks.update(null,new LambdaUpdateWrapper<NotificationTask>()
.eq(NotificationTask::getId,taskId).eq(NotificationTask::getStatus,"RETRYING")
.set(NotificationTask::getStatus,"DISPATCHING").set(NotificationTask::getUpdatedAt,java.time.LocalDateTime.now()))==1; }
public int recoverTimedOutDispatching(java.time.LocalDateTime staleBefore,java.time.LocalDateTime now,int maxAttempts) {
int dead = tasks.deadTimedOutDispatchingWithDeliveryEvidence(staleBefore,now);
dead += tasks.deadTimedOutDispatchingAtAttemptLimit(staleBefore,now,maxAttempts);
return dead + tasks.retryTimedOutDispatchingWithoutDeliveryEvidence(staleBefore,now,maxAttempts);
}
public List<NotificationTask> retryingTasks(java.time.LocalDateTime dueBefore,int maxAttempts) { return tasks.selectList(new LambdaQueryWrapper<NotificationTask>()
.eq(NotificationTask::getStatus,"RETRYING").lt(NotificationTask::getAttemptCount,maxAttempts)
.le(NotificationTask::getUpdatedAt,dueBefore).orderByAsc(NotificationTask::getUpdatedAt).last("LIMIT 100")); }
public boolean claimRetrying(Long taskId,int maxAttempts,java.time.LocalDateTime dueBefore,java.time.LocalDateTime now) {
return tasks.claimRetrying(taskId,maxAttempts,dueBefore,now)==1;
}
public boolean completeDispatch(Long taskId,java.time.LocalDateTime now) { return tasks.update(null,new LambdaUpdateWrapper<NotificationTask>()
.eq(NotificationTask::getId,taskId).eq(NotificationTask::getStatus,"DISPATCHING")
.set(NotificationTask::getStatus,"SENT").set(NotificationTask::getSentAt,now)
.set(NotificationTask::getLastError,null).set(NotificationTask::getUpdatedAt,now))==1; }
public boolean failDispatch(Long taskId,int maxAttempts,String error,java.time.LocalDateTime now) {
return tasks.failDispatch(taskId,maxAttempts,error,now)==1;
}
public boolean requeueForManualRetry(Long taskId,java.time.LocalDateTime now) { return tasks.update(null,new LambdaUpdateWrapper<NotificationTask>()
.eq(NotificationTask::getId,taskId).in(NotificationTask::getStatus,"RETRYING","DEAD")
.set(NotificationTask::getStatus,"RETRYING").set(NotificationTask::getAttemptCount,0)
.set(NotificationTask::getLastError,null).set(NotificationTask::getSentAt,null)
.set(NotificationTask::getUpdatedAt,now))==1; }
public void insertInbox(NotificationInbox value) { inbox.insert(value); }
public boolean markRecipientDelivered(Long recipientId,java.time.LocalDateTime now) { return recipients.update(null,new LambdaUpdateWrapper<NotificationRecipient>()
.eq(NotificationRecipient::getId,recipientId).ne(NotificationRecipient::getStatus,"DELIVERED")
.set(NotificationRecipient::getStatus,"DELIVERED").set(NotificationRecipient::getFailureReason,null)
.set(NotificationRecipient::getDeliveredAt,now).set(NotificationRecipient::getUpdatedAt,now))==1; }
public int markUndeliveredRecipientsFailed(Long taskId,String error,java.time.LocalDateTime now) { return recipients.update(null,new LambdaUpdateWrapper<NotificationRecipient>()
.eq(NotificationRecipient::getTaskId,taskId).ne(NotificationRecipient::getStatus,"DELIVERED")
.set(NotificationRecipient::getStatus,"FAILED").set(NotificationRecipient::getFailureReason,error)
.set(NotificationRecipient::getUpdatedAt,now)); }
public PageResult<NotificationInbox> pageInbox(long tenantId,long membershipId,String app,long page,long size,String status,String category) {
return PageResult.from(inbox.selectPage(new Page<>(page,size), new LambdaQueryWrapper<NotificationInbox>()
.eq(NotificationInbox::getTenantId,tenantId).eq(NotificationInbox::getMembershipId,membershipId)
@@ -0,0 +1,74 @@
package com.baiyue.iam.notification.service;
import com.baiyue.iam.notification.entity.NotificationInbox;
import com.baiyue.iam.notification.entity.NotificationRecipient;
import com.baiyue.iam.notification.entity.NotificationTask;
import com.baiyue.iam.notification.repository.NotificationRepository;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
@Service
public class NotificationDispatchTransactionService {
static final int MAX_DISPATCH_ATTEMPTS = 3;
static final long RETRY_DELAY_SECONDS = 30;
@Resource private NotificationRepository repository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public boolean claimScheduled(Long taskId) {
return repository.claimScheduled(taskId, MAX_DISPATCH_ATTEMPTS, LocalDateTime.now());
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public boolean claimRetrying(Long taskId) {
LocalDateTime now = LocalDateTime.now();
return repository.claimRetrying(taskId, MAX_DISPATCH_ATTEMPTS,
now.minusSeconds(RETRY_DELAY_SECONDS), now);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void deliverClaimed(Long taskId) {
NotificationTask task = repository.task(taskId);
if (task == null || !"DISPATCHING".equals(task.getStatus())) {
throw new IllegalStateException("NOTIFICATION_DISPATCH_CLAIM_LOST");
}
LocalDateTime now = LocalDateTime.now();
for (NotificationRecipient recipient : repository.recipients(taskId)) {
if ("DELIVERED".equals(recipient.getStatus())) continue;
NotificationInbox item = new NotificationInbox();
item.setId(IdWorker.getId()); item.setTaskId(taskId); item.setRecipientId(recipient.getId());
item.setTenantId(recipient.getTenantId()); item.setMembershipId(recipient.getMembershipId());
item.setAppCode(recipient.getAppCode()); item.setCategory(task.getCategory()); item.setTitle(task.getTitle());
item.setBody(task.getBody()); item.setSourceType(task.getSourceEventType()); item.setSourceId(task.getSourceEventId());
item.setStatus("UNREAD"); item.setSentAt(now); item.setCreatedAt(now); item.setUpdatedAt(now);
repository.insertInbox(item);
if (!repository.markRecipientDelivered(recipient.getId(), now)) {
throw new IllegalStateException("NOTIFICATION_RECIPIENT_DELIVERY_STATE_LOST");
}
}
if (!repository.completeDispatch(taskId, now)) {
throw new IllegalStateException("NOTIFICATION_DISPATCH_CLAIM_LOST");
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void recordFailure(Long taskId, Throwable failure) {
LocalDateTime now = LocalDateTime.now();
String error = boundedError(failure);
repository.markUndeliveredRecipientsFailed(taskId, error, now);
if (!repository.failDispatch(taskId, MAX_DISPATCH_ATTEMPTS, error, now)) {
throw new IllegalStateException("NOTIFICATION_DISPATCH_CLAIM_LOST");
}
}
private String boundedError(Throwable failure) {
String message = failure.getMessage();
String error = message == null || message.isBlank() ? failure.getClass().getSimpleName() : message;
return error.length() <= 1000 ? error : error.substring(0, 1000);
}
}
@@ -25,6 +25,7 @@ import java.util.Set;
@Service
public class NotificationService {
private static final long EVENT_PROCESSING_TIMEOUT_MINUTES = 5;
private static final int INBOX_SUMMARY_MAX_CODE_POINTS = 120;
public enum EventClaimStatus { ACQUIRED, PROCESSED, PROCESSING }
public record EventClaim(NotificationEventInbox event, EventClaimStatus status) { }
@@ -33,6 +34,7 @@ public class NotificationService {
@Resource private NotificationTemplateRenderer renderer;
@Resource private NotificationRecipientResolver resolver;
@Resource private ObjectMapper objectMapper;
@Resource private NotificationDispatchTransactionService dispatchTransactions;
@Transactional(readOnly = true)
public PageResult<NotificationSummary> inbox(long tenantId, long membershipId, String appCode,
@@ -183,35 +185,44 @@ public class NotificationService {
public int recoverTimedOutDispatching(long timeoutSeconds) {
if (timeoutSeconds < 1) throw new IllegalArgumentException("Dispatch timeout must be positive");
LocalDateTime now = eventNow();
return repository.recoverTimedOutDispatching(now.minusSeconds(timeoutSeconds), now);
return repository.recoverTimedOutDispatching(now.minusSeconds(timeoutSeconds), now,
NotificationDispatchTransactionService.MAX_DISPATCH_ATTEMPTS);
}
@Transactional(readOnly = true)
public List<Long> dueScheduledTaskIds() {
return repository.dueTasks().stream().map(NotificationTask::getId).toList();
return repository.dueTasks(LocalDateTime.now()).stream().map(NotificationTask::getId).toList();
}
@Transactional(readOnly = true)
public List<Long> retryingTaskIds() {
return repository.retryingTasks().stream().map(NotificationTask::getId).toList();
LocalDateTime dueBefore = LocalDateTime.now()
.minusSeconds(NotificationDispatchTransactionService.RETRY_DELAY_SECONDS);
return repository.retryingTasks(dueBefore, NotificationDispatchTransactionService.MAX_DISPATCH_ATTEMPTS)
.stream().map(NotificationTask::getId).toList();
}
@Transactional
public boolean dispatchScheduled(Long taskId) {
if (!repository.claimScheduled(taskId)) return false;
NotificationTask task = requiredTask(taskId);
task.setStatus("DISPATCHING");
deliver(task, LocalDateTime.now());
return true;
return dispatch(taskId, dispatchTransactions.claimScheduled(taskId));
}
@Transactional
public boolean dispatchRetrying(Long taskId) {
if (!repository.claimRetrying(taskId)) return false;
NotificationTask task = requiredTask(taskId);
task.setStatus("DISPATCHING");
deliver(task, LocalDateTime.now());
return true;
return dispatch(taskId, dispatchTransactions.claimRetrying(taskId));
}
private boolean dispatch(Long taskId, boolean claimed) {
if (!claimed) return false;
try {
dispatchTransactions.deliverClaimed(taskId);
return true;
} catch (RuntimeException failure) {
try {
dispatchTransactions.recordFailure(taskId, failure);
} catch (RuntimeException persistenceFailure) {
failure.addSuppressed(persistenceFailure);
}
throw failure;
}
}
@Transactional(readOnly = true)
@@ -280,6 +291,19 @@ public class NotificationService {
source.page(), source.size(), source.total(), source.pages());
}
@Transactional(readOnly = true)
public TaskDetail taskDetail(Long id) {
NotificationTask task = requiredTask(id);
List<TaskRecipientItem> recipients = repository.recipients(id).stream()
.map(value -> new TaskRecipientItem(value.getId(), value.getDisplayName(), value.getAppCode(),
value.getStatus(), value.getFailureReason(), value.getDeliveredAt()))
.toList();
return new TaskDetail(task.getId(), task.getTaskNo(), task.getCategory(), task.getTitle(), task.getStatus(),
task.getRecipientCount(), task.getAttemptCount() == null ? 0 : task.getAttemptCount(),
task.getScheduledAt(), task.getSentAt(),
task.getLastError(), recipients);
}
@Transactional
@BusinessAudit(event = "NOTIFICATION_TASK_CANCEL", resourceType = "NOTIFICATION_TASK",
resourceId = "#id", description = "取消消息任务", captureAfter = false)
@@ -298,8 +322,9 @@ public class NotificationService {
NotificationTask task = requiredTask(id);
if (!Set.of("RETRYING", "DEAD").contains(task.getStatus()))
fail(ResultCode.CONFLICT, "NOTIFICATION_INVALID_STATE");
task.setStatus("RETRYING"); task.setAttemptCount(task.getAttemptCount() + 1); repository.saveTask(task);
deliver(task, LocalDateTime.now());
LocalDateTime now = LocalDateTime.now();
if (!repository.requeueForManualRetry(id, now)) fail(ResultCode.CONFLICT, "NOTIFICATION_INVALID_STATE");
task.setStatus("RETRYING"); task.setAttemptCount(0); task.setLastError(null); task.setUpdatedAt(now);
return taskItem(task, false);
}
@@ -319,12 +344,21 @@ public class NotificationService {
return value;
}
private NotificationSummary summary(NotificationInbox value) {
return new NotificationSummary(value.getId(), value.getCategory(), value.getTitle(), value.getStatus(),
value.getSentAt(), value.getReadAt());
return new NotificationSummary(value.getId(), value.getCategory(), value.getTitle(),
boundedSummary(value.getBody()), value.getStatus(), value.getSentAt(), value.getReadAt());
}
private String boundedSummary(String body) {
if (body == null || body.isBlank()) return "";
String compact = body.strip().replaceAll("\\s+", " ");
int count = compact.codePointCount(0, compact.length());
if (count <= INBOX_SUMMARY_MAX_CODE_POINTS) return compact;
return compact.substring(0, compact.offsetByCodePoints(0, INBOX_SUMMARY_MAX_CODE_POINTS));
}
private NotificationDetail detail(NotificationInbox value) {
return new NotificationDetail(value.getId(), value.getCategory(), value.getTitle(), value.getBody(),
value.getStatus(), value.getSourceType(), value.getSourceId(), value.getSentAt(), value.getReadAt());
return new NotificationDetail(value.getId(), value.getCategory(), value.getTitle(),
boundedSummary(value.getBody()), value.getBody(), value.getStatus(), value.getSourceType(),
value.getSourceId(), value.getSentAt(), value.getReadAt());
}
private TemplateResponse template(NotificationTemplate value) {
return new TemplateResponse(value.getId(), value.getTemplateCode(), value.getTemplateName(),
@@ -31,7 +31,7 @@ public class CustomerListExportProviderService {
private static final int MAX_BATCH_SIZE = 500;
private static final int MAX_SNAPSHOT_TOKEN_LENGTH = 512;
private static final Set<String> STATUSES = Set.of(
"PROFILE_REQUIRED", "PENDING", "ACTIVE", "REJECTED", "DISABLED");
"PROFILE_REQUIRED", "PENDING", "ACTIVE", "REJECTED", "DISABLED", "ARCHIVED");
private static final Set<String> PARAMETER_KEYS = Set.of("keyword", "status");
private static final List<ExportColumn> COLUMNS = List.of(
column("id", "客户ID", ExportColumnType.STRING, 20),
@@ -205,6 +205,7 @@ public class CustomerListExportProviderService {
case "ACTIVE" -> "启用";
case "REJECTED" -> "已驳回";
case "DISABLED" -> "已停用";
case "ARCHIVED" -> "已归档";
default -> status;
};
}
@@ -93,7 +93,7 @@ public class TenantService {
captureBefore = false, captureAfter = false)
public TenantResponse archiveMerchant(long tenantId) {
IamTenant tenant = requireMerchantForUpdate(tenantId);
if ("DISABLED".equals(tenant.getStatus())) return response(tenant);
if ("ARCHIVED".equals(tenant.getStatus())) return response(tenant);
if (!Set.of("PROFILE_REQUIRED", "REJECTED").contains(tenant.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "当前客户状态不允许归档");
}
@@ -106,7 +106,7 @@ public class TenantService {
} catch (FeignException ex) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用");
}
tenant.setStatus("DISABLED");
tenant.setStatus("ARCHIVED");
repository.saveTenant(tenant);
return response(tenant);
}
@@ -350,6 +350,7 @@ public class TenantService {
description = "平台编辑企业客户档案")
public TenantResponse updateMerchant(long tenantId, UpdateTenantRequest request) {
IamTenant tenant = requireMerchant(tenantId);
ensureNotArchived(tenant);
String contactPhone = normalizeContactPhone(request.contactPhone());
String existingContactPhone = normalizeContactPhone(tenant.getContactPhone());
if (!contactPhone.equals(existingContactPhone)) {
@@ -429,6 +430,7 @@ public class TenantService {
resourceId = "#tenantId", description = "编辑企业资料")
public TenantResponse update(long tenantId, UpdateProfileRequest request) {
IamTenant tenant = requireMerchant(tenantId);
ensureNotArchived(tenant);
tenant.setTenantName(request.tenantName());
tenant.setLegalName(request.legalName());
tenant.setLegalRepresentative(request.legalRepresentative());
@@ -443,6 +445,12 @@ public class TenantService {
return tenant.getId() != null && tenant.getId().longValue() == id;
}
private void ensureNotArchived(IamTenant tenant) {
if ("ARCHIVED".equals(tenant.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "Archived customer is read-only / 已归档客户仅可查看");
}
}
private String normalizeContactPhone(String contactPhone) {
return contactPhone == null ? "" : contactPhone.trim();
}
+1 -1
View File
@@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS iam_tenant (
tenant_code VARCHAR(64) NOT NULL COMMENT '全局唯一客户编码',
tenant_name VARCHAR(128) NOT NULL COMMENT '客户企业名称',
tenant_type VARCHAR(20) NOT NULL DEFAULT 'MERCHANT' COMMENT '主体类型:MERCHANT企业客户',
status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' COMMENT '客户状态:PROFILE_REQUIRED待完善资料、PENDING待审核、ACTIVE正常、REJECTED已驳回、DISABLED已停用',
status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' COMMENT '客户状态:PROFILE_REQUIRED待完善资料、PENDING待审核、ACTIVE正常、REJECTED已驳回、DISABLED已停用、ARCHIVED已归档',
legal_name VARCHAR(128) NULL COMMENT '企业法定名称',
legal_representative VARCHAR(64) NULL COMMENT '法定代表人',
unified_social_credit_code VARCHAR(32) NULL COMMENT '统一社会信用代码',
+2 -2
View File
@@ -45,7 +45,7 @@ paths:
- {name: page, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}}
- {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 20}}
- {name: keyword, in: query, required: false, schema: {type: string}, description: 客户名称关键字,不支持客户编号、信用代码或联系人搜索}
- {name: status, in: query, required: false, schema: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED]}}
- {name: status, in: query, required: false, schema: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED, ARCHIVED]}}
responses:
'200':
description: 企业客户分页查询成功
@@ -985,7 +985,7 @@ components:
id: {type: string, pattern: '^\d+$', example: '190000000000000001'}
tenantCode: {type: string}
tenantName: {type: string}
status: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED]}
status: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED, ARCHIVED]}
legalName: {type: string}
legalRepresentative: {type: string, maxLength: 64}
unifiedSocialCreditCode: {type: string}
@@ -50,7 +50,7 @@ paths:
tags: [Platform notification administration]
summary: Create an IN_APP notification task
requestBody: {$ref: '#/components/requestBodies/CreateNotification'}
responses: {'200': {$ref: '#/components/responses/TaskDetail'}}
responses: {'200': {$ref: '#/components/responses/TaskItem'}}
/platform/v1/notification-templates:
get:
tags: [Platform notification administration]
@@ -95,18 +95,26 @@ paths:
- {name: title, in: query, description: Fuzzy match rendered title, schema: {type: string, maxLength: 128}}
- {name: status, in: query, schema: {type: string, enum: [CREATED, SCHEDULED, DISPATCHING, RETRYING, SENT, DEAD, CANCELLED]}}
responses: {'200': {$ref: '#/components/responses/TaskPage'}}
/platform/v1/notification-tasks/{id}:
get:
tags: [Platform notification administration]
summary: Get notification task delivery details
description: Uses the task list permission and exposes bounded operational evidence without message body content.
parameters: [{$ref: '#/components/parameters/IdPath'}]
responses: {'200': {$ref: '#/components/responses/TaskDetail'}}
/platform/v1/notification-tasks/{id}/cancel:
put:
tags: [Platform notification administration]
summary: Cancel a CREATED or SCHEDULED task
parameters: [{$ref: '#/components/parameters/IdPath'}]
responses: {'200': {$ref: '#/components/responses/TaskDetail'}}
responses: {'200': {$ref: '#/components/responses/TaskItem'}}
/platform/v1/notification-tasks/{id}/retry:
put:
tags: [Platform notification administration]
summary: Retry a RETRYING or DEAD task
summary: Requeue a RETRYING or DEAD task
description: Resets the current delivery-attempt counter and returns the task in RETRYING; scheduler delivery starts after the retry delay.
parameters: [{$ref: '#/components/parameters/IdPath'}]
responses: {'200': {$ref: '#/components/responses/TaskDetail'}}
responses: {'200': {$ref: '#/components/responses/TaskItem'}}
components:
parameters:
AppPath: {name: app, in: path, required: true, schema: {type: string, enum: [platform, merchant]}}
@@ -126,6 +134,7 @@ components:
TemplatePage: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TemplatePageResponse'}}}}
TemplateDetail: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TemplateDetailResponse'}}}}
TaskPage: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TaskPageResponse'}}}}
TaskItem: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TaskItemResponse'}}}}
TaskDetail: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TaskDetailResponse'}}}}
schemas:
CreateNotificationRequest:
@@ -159,10 +168,12 @@ components:
type: object
required: [targetStatus, version]
properties: {targetStatus: {type: string, enum: [ACTIVE, DISABLED], description: ACTIVE is accepted only from DRAFT; DISABLED only from ACTIVE.}, version: {type: integer, minimum: 1}}
InboxItem: {type: object, required: [id, category, title, status, sentAt], properties: {id: {type: integer, format: int64}, category: {type: string}, title: {type: string}, status: {type: string, enum: [UNREAD, READ]}, sentAt: {type: string, format: date-time}, readAt: {type: string, format: date-time, nullable: true}}}
InboxItem: {type: object, required: [id, category, title, summary, status, sentAt], properties: {id: {type: integer, format: int64}, category: {type: string}, title: {type: string}, summary: {type: string, maxLength: 120, description: Whitespace-compacted body preview limited to 120 Unicode code points; never contains the full body unless the body itself fits.}, status: {type: string, enum: [UNREAD, READ]}, sentAt: {type: string, format: date-time}, readAt: {type: string, format: date-time, nullable: true}}}
InboxDetail: {allOf: [{$ref: '#/components/schemas/InboxItem'}, {type: object, required: [body], properties: {body: {type: string}, sourceType: {type: string, nullable: true}, sourceId: {type: string, nullable: true}}}]}
TemplateItem: {type: object, required: [id, templateCode, templateName, category, channel, titleTemplate, bodyTemplate, variableWhitelist, status, version], properties: {id: {type: integer, format: int64}, templateCode: {type: string}, templateName: {type: string}, category: {type: string}, channel: {type: string, enum: [IN_APP]}, titleTemplate: {type: string}, bodyTemplate: {type: string}, variableWhitelist: {type: array, items: {type: string}}, status: {type: string, enum: [DRAFT, ACTIVE, DISABLED]}, version: {type: integer}}}
TaskItem: {type: object, required: [id, taskNo, category, title, status, recipientCount, duplicated], properties: {id: {type: integer, format: int64}, taskNo: {type: string}, category: {type: string}, title: {type: string}, status: {type: string, enum: [CREATED, SCHEDULED, DISPATCHING, RETRYING, SENT, DEAD, CANCELLED]}, recipientCount: {type: integer}, scheduledAt: {type: string, format: date-time, nullable: true}, sentAt: {type: string, format: date-time, nullable: true}, duplicated: {type: boolean}}}
TaskRecipientItem: {type: object, required: [id, displayName, appCode, status], properties: {id: {type: integer, format: int64}, displayName: {type: string}, appCode: {type: string}, status: {type: string}, failureReason: {type: string, nullable: true}, deliveredAt: {type: string, format: date-time, nullable: true}}}
TaskDetail: {type: object, required: [id, taskNo, category, title, status, recipientCount, attemptCount, recipients], properties: {id: {type: integer, format: int64}, taskNo: {type: string}, category: {type: string}, title: {type: string}, status: {type: string, enum: [CREATED, SCHEDULED, DISPATCHING, RETRYING, SENT, DEAD, CANCELLED]}, recipientCount: {type: integer}, attemptCount: {type: integer, minimum: 0, maximum: 3, description: Number of started delivery attempts in the current automatic retry cycle.}, scheduledAt: {type: string, format: date-time, nullable: true}, sentAt: {type: string, format: date-time, nullable: true}, lastError: {type: string, nullable: true}, recipients: {type: array, items: {$ref: '#/components/schemas/TaskRecipientItem'}}}}
InboxPageData: {type: object, required: [records, page, size, total, pages], properties: {records: {type: array, items: {$ref: '#/components/schemas/InboxItem'}}, page: {type: integer}, size: {type: integer}, total: {type: integer}, pages: {type: integer}}}
TemplatePageData: {type: object, required: [records, page, size, total, pages], properties: {records: {type: array, items: {$ref: '#/components/schemas/TemplateItem'}}, page: {type: integer}, size: {type: integer}, total: {type: integer}, pages: {type: integer}}}
TaskPageData: {type: object, required: [records, page, size, total, pages], properties: {records: {type: array, items: {$ref: '#/components/schemas/TaskItem'}}, page: {type: integer}, size: {type: integer}, total: {type: integer}, pages: {type: integer}}}
@@ -173,4 +184,5 @@ components:
TemplatePageResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/TemplatePageData'}}}
TemplateDetailResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/TemplateItem'}}}
TaskPageResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/TaskPageData'}}}
TaskDetailResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/TaskItem'}}}
TaskItemResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/TaskItem'}}}
TaskDetailResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/TaskDetail'}}}
@@ -31,7 +31,7 @@ class PlatformTenantControllerContractTest {
assertNotNull(annotation(method, 0, Min.class));
assertNotNull(annotation(method, 1, Min.class));
assertEquals(200, annotation(method, 1, Max.class).value());
assertEquals("PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED",
assertEquals("PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED|ARCHIVED",
annotation(method, 3, Pattern.class).regexp());
Set<String> statuses = Arrays.stream(method.getAnnotation(ApiResponses.class).value())
@@ -0,0 +1,126 @@
package com.baiyue.iam.notification;
import com.baiyue.iam.notification.entity.NotificationRecipient;
import com.baiyue.iam.notification.entity.NotificationTask;
import com.baiyue.iam.notification.repository.NotificationRepository;
import com.baiyue.iam.notification.service.NotificationDispatchTransactionService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class NotificationDispatchTransactionServiceTest {
private final NotificationRepository repository = mock(NotificationRepository.class);
private final NotificationDispatchTransactionService service = new NotificationDispatchTransactionService();
@BeforeEach void setup() {
ReflectionTestUtils.setField(service, "repository", repository);
}
@Test void claimDeliveryAndFailureEvidenceUseIndependentTransactions() throws Exception {
for (var method : List.of(
NotificationDispatchTransactionService.class.getMethod("claimScheduled", Long.class),
NotificationDispatchTransactionService.class.getMethod("claimRetrying", Long.class),
NotificationDispatchTransactionService.class.getMethod("deliverClaimed", Long.class),
NotificationDispatchTransactionService.class.getMethod("recordFailure", Long.class, Throwable.class))) {
assertThat(method.getAnnotation(Transactional.class)).isNotNull()
.extracting(Transactional::propagation)
.isEqualTo(Propagation.REQUIRES_NEW);
}
}
@Test void claimStartsOneOfThreeAllowedAttempts() {
when(repository.claimScheduled(eq(41L), eq(3), any())).thenReturn(true);
assertThat(service.claimScheduled(41L)).isTrue();
verify(repository).claimScheduled(eq(41L), eq(3), any());
}
@Test void retryClaimEnforcesDelayAndSameAttemptLimit() {
when(repository.claimRetrying(eq(41L), eq(3), any(), any())).thenReturn(true);
assertThat(service.claimRetrying(41L)).isTrue();
ArgumentCaptor<LocalDateTime> dueBefore = ArgumentCaptor.forClass(LocalDateTime.class);
ArgumentCaptor<LocalDateTime> now = ArgumentCaptor.forClass(LocalDateTime.class);
verify(repository).claimRetrying(eq(41L), eq(3), dueBefore.capture(), now.capture());
assertThat(dueBefore.getValue()).isEqualTo(now.getValue().minusSeconds(30));
}
@Test void successfulDeliveryWritesInboxThenCompletesTaskAndClearsRecipientFailure() {
NotificationTask task = task("DISPATCHING");
NotificationRecipient recipient = recipient("FAILED");
when(repository.task(41L)).thenReturn(task);
when(repository.recipients(41L)).thenReturn(List.of(recipient));
when(repository.markRecipientDelivered(eq(51L), any())).thenReturn(true);
when(repository.completeDispatch(eq(41L), any())).thenReturn(true);
service.deliverClaimed(41L);
verify(repository).insertInbox(argThat(item -> item.getTaskId().equals(41L)
&& item.getRecipientId().equals(51L) && "UNREAD".equals(item.getStatus())));
var ordered = inOrder(repository);
ordered.verify(repository).markRecipientDelivered(eq(51L), any());
ordered.verify(repository).completeDispatch(eq(41L), any());
}
@Test void alreadyDeliveredRecipientIsNotInsertedAgain() {
NotificationTask task = task("DISPATCHING");
NotificationRecipient recipient = recipient("DELIVERED");
when(repository.task(41L)).thenReturn(task);
when(repository.recipients(41L)).thenReturn(List.of(recipient));
when(repository.completeDispatch(eq(41L), any())).thenReturn(true);
service.deliverClaimed(41L);
verify(repository, never()).insertInbox(any());
verify(repository, never()).markRecipientDelivered(anyLong(), any());
}
@Test void failurePersistsBoundedEvidenceAndUsesAttemptLimit() {
when(repository.failDispatch(eq(41L), eq(3), anyString(), any())).thenReturn(true);
service.recordFailure(41L, new IllegalStateException("x".repeat(1200)));
ArgumentCaptor<String> error = ArgumentCaptor.forClass(String.class);
verify(repository).markUndeliveredRecipientsFailed(eq(41L), error.capture(), any());
assertThat(error.getValue()).hasSize(1000);
verify(repository).failDispatch(eq(41L), eq(3), eq(error.getValue()), any());
}
@Test void rejectsDeliveryWithoutCurrentClaim() {
when(repository.task(41L)).thenReturn(task("RETRYING"));
assertThatThrownBy(() -> service.deliverClaimed(41L))
.isInstanceOf(IllegalStateException.class)
.hasMessage("NOTIFICATION_DISPATCH_CLAIM_LOST");
verify(repository, never()).insertInbox(any());
}
private NotificationTask task(String status) {
NotificationTask task = new NotificationTask();
task.setId(41L); task.setStatus(status); task.setCategory("ORDER"); task.setTitle("状态变化");
task.setBody("正文"); task.setSourceEventId("event-1"); task.setSourceEventType("order.status.changed");
return task;
}
private NotificationRecipient recipient(String status) {
NotificationRecipient recipient = new NotificationRecipient();
recipient.setId(51L); recipient.setTaskId(41L); recipient.setTenantId(9L);
recipient.setMembershipId(11L); recipient.setAppCode("MERCHANT"); recipient.setStatus(status);
recipient.setFailureReason("old error"); recipient.setUpdatedAt(LocalDateTime.now());
return recipient;
}
}
@@ -1,6 +1,7 @@
package com.baiyue.iam.notification;
import com.baiyue.iam.notification.controller.NotificationInboxController;
import com.baiyue.iam.notification.controller.PlatformNotificationController;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -18,6 +19,15 @@ class NotificationInboxPermissionContractTest {
assertPermission("readAll", "hasAuthority(#app + ':notification:read-all')");
}
@Test
void taskDetailUsesTheTaskListPermission() {
var method = Arrays.stream(PlatformNotificationController.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals("taskDetail"))
.findFirst().orElseThrow();
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:notification:task:list')");
}
private static void assertPermission(String methodName, String expected) {
var method = Arrays.stream(NotificationInboxController.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals(methodName))
@@ -28,6 +28,7 @@ class NotificationOpenApiContractTest {
"/{app}/v1/notifications/read-all", "/platform/v1/notifications",
"/platform/v1/notification-templates", "/platform/v1/notification-templates/{id}",
"/platform/v1/notification-templates/{id}/status", "/platform/v1/notification-tasks",
"/platform/v1/notification-tasks/{id}",
"/platform/v1/notification-tasks/{id}/cancel", "/platform/v1/notification-tasks/{id}/retry");
assertThat(paths).doesNotContainKey("/merchant/v1/notifications:create");
@@ -37,6 +38,7 @@ class NotificationOpenApiContractTest {
assertRequestBody(paths, "/platform/v1/notification-templates/{id}/status", "put");
for (String path : List.of("/platform/v1/notification-templates/{id}",
"/platform/v1/notification-templates/{id}/status",
"/platform/v1/notification-tasks/{id}",
"/platform/v1/notification-tasks/{id}/cancel",
"/platform/v1/notification-tasks/{id}/retry")) {
Map<String, Object> operation = map(map(paths.get(path)).values().iterator().next());
@@ -50,6 +52,7 @@ class NotificationOpenApiContractTest {
"UpdateTemplateRequest", "ChangeTemplateStatusRequest", "InboxPageResponse",
"InboxDetailResponse", "UnreadCountResponse", "ReadAllResponse",
"TemplateItem", "TemplateDetailResponse", "TaskItem", "TaskDetailResponse");
assertThat(schemas).containsKeys("TaskItemResponse", "TaskRecipientItem", "TaskDetail");
for (String schema : List.of("CreateNotificationRequest", "CreateTemplateRequest",
"UpdateTemplateRequest", "ChangeTemplateStatusRequest", "InboxItem", "TemplateItem",
"TaskItem", "InboxPageResponse", "TemplateDetailResponse", "TaskDetailResponse")) {
@@ -59,6 +62,13 @@ class NotificationOpenApiContractTest {
.contains("DISABLED cannot return to ACTIVE")
.contains("does not support deleting or clearing read messages")
.contains("name: taskNo").contains("name: templateCode").contains("name: title");
Map<String, Object> inboxItem = map(schemas.get("InboxItem"));
assertThat(list(inboxItem.get("required"))).contains("summary");
assertThat(map(map(inboxItem.get("properties")).get("summary"))).containsEntry("maxLength", 120);
Map<String, Object> taskDetail = map(schemas.get("TaskDetail"));
assertThat(map(map(taskDetail.get("properties")).get("attemptCount")))
.containsEntry("minimum", 0).containsEntry("maximum", 3);
assertThat(document).contains("scheduler delivery starts after the retry delay");
}
private void assertRequestBody(Map<String, Object> paths, String path, String method) {
@@ -13,15 +13,34 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
class NotificationSchedulerRecoveryTest {
@Test void claimSqlAtomicallyIncrementsAttemptsAndEnforcesDueTimeAndLimit() throws Exception {
Update scheduled = NotificationTaskMapper.class
.getMethod("claimScheduled", Long.class, int.class, java.time.LocalDateTime.class)
.getAnnotation(Update.class);
String scheduledSql = String.join(" ", scheduled.value()).replaceAll("\\s+", " ").toUpperCase();
assertThat(scheduledSql).contains("STATUS = 'DISPATCHING'", "ATTEMPT_COUNT = ATTEMPT_COUNT + 1",
"STATUS = 'SCHEDULED'", "SCHEDULED_AT <= #{NOW}", "ATTEMPT_COUNT < #{MAXATTEMPTS}");
Update retrying = NotificationTaskMapper.class
.getMethod("claimRetrying", Long.class, int.class, java.time.LocalDateTime.class,
java.time.LocalDateTime.class)
.getAnnotation(Update.class);
String retryingSql = String.join(" ", retrying.value()).replaceAll("\\s+", " ").toUpperCase();
assertThat(retryingSql).contains("STATUS = 'DISPATCHING'", "ATTEMPT_COUNT = ATTEMPT_COUNT + 1",
"STATUS = 'RETRYING'", "UPDATED_AT <= #{DUEBEFORE}", "ATTEMPT_COUNT < #{MAXATTEMPTS}");
}
@Test void recoverySqlRequiresTimeoutAndNoSuccessfulDeliveryEvidence() throws Exception {
Update retry = NotificationTaskMapper.class
.getMethod("retryTimedOutDispatchingWithoutDeliveryEvidence", java.time.LocalDateTime.class, java.time.LocalDateTime.class)
.getMethod("retryTimedOutDispatchingWithoutDeliveryEvidence", java.time.LocalDateTime.class,
java.time.LocalDateTime.class, int.class)
.getAnnotation(Update.class);
String retrySql = String.join(" ", retry.value()).replaceAll("\\s+", " ").toUpperCase();
assertThat(retrySql).contains("TASK.STATUS = 'DISPATCHING'", "TASK.UPDATED_AT <= #{STALEBEFORE}",
"TASK.SENT_AT IS NULL", "RECIPIENT.STATUS = 'DELIVERED'", "IAM_NOTIFICATION_INBOX")
.contains("SET TASK.STATUS = 'RETRYING'");
.contains("SET TASK.STATUS = 'RETRYING'", "TASK.ATTEMPT_COUNT < #{MAXATTEMPTS}")
.doesNotContain("ATTEMPT_COUNT = TASK.ATTEMPT_COUNT + 1");
Update dead = NotificationTaskMapper.class
.getMethod("deadTimedOutDispatchingWithDeliveryEvidence", java.time.LocalDateTime.class, java.time.LocalDateTime.class)
@@ -30,6 +49,21 @@ class NotificationSchedulerRecoveryTest {
assertThat(deadSql).contains("SET TASK.STATUS = 'DEAD'", "TASK.STATUS = 'DISPATCHING'",
"TASK.UPDATED_AT <= #{STALEBEFORE}", "RECIPIENT.STATUS = 'DELIVERED'", "IAM_NOTIFICATION_INBOX",
"DISPATCH_TIMEOUT_WITH_DELIVERY_EVIDENCE");
Update attemptLimit = NotificationTaskMapper.class
.getMethod("deadTimedOutDispatchingAtAttemptLimit", java.time.LocalDateTime.class,
java.time.LocalDateTime.class, int.class)
.getAnnotation(Update.class);
String attemptLimitSql = String.join(" ", attemptLimit.value()).replaceAll("\\s+", " ").toUpperCase();
assertThat(attemptLimitSql).contains("SET TASK.STATUS = 'DEAD'",
"TASK.ATTEMPT_COUNT >= #{MAXATTEMPTS}", "DISPATCH_TIMEOUT_ATTEMPT_LIMIT");
Update failed = NotificationTaskMapper.class
.getMethod("failDispatch", Long.class, int.class, String.class, java.time.LocalDateTime.class)
.getAnnotation(Update.class);
String failedSql = String.join(" ", failed.value()).replaceAll("\\s+", " ").toUpperCase();
assertThat(failedSql).contains("STATUS = CASE WHEN ATTEMPT_COUNT >= #{MAXATTEMPTS} THEN 'DEAD' ELSE 'RETRYING' END",
"LAST_ERROR = #{ERROR}", "STATUS = 'DISPATCHING'");
}
@Test void schedulerRecoversBeforeClaimingScheduledAndRetryingTasks() {
@@ -4,10 +4,12 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.notification.dto.NotificationDtos.*;
import com.baiyue.iam.notification.entity.NotificationRecipient;
import com.baiyue.iam.notification.entity.NotificationEventInbox;
import com.baiyue.iam.notification.entity.NotificationInbox;
import com.baiyue.iam.notification.entity.NotificationTask;
import com.baiyue.iam.notification.entity.NotificationTemplate;
import com.baiyue.iam.notification.repository.NotificationRepository;
import com.baiyue.iam.notification.service.NotificationRecipientResolver;
import com.baiyue.iam.notification.service.NotificationDispatchTransactionService;
import com.baiyue.iam.notification.service.NotificationService;
import com.baiyue.iam.notification.service.NotificationTemplateRenderer;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -27,6 +29,8 @@ import static org.mockito.Mockito.*;
class NotificationServiceTest {
private final NotificationRepository repository = mock(NotificationRepository.class);
private final NotificationRecipientResolver resolver = mock(NotificationRecipientResolver.class);
private final NotificationDispatchTransactionService dispatchTransactions =
mock(NotificationDispatchTransactionService.class);
private final NotificationService service = new NotificationService();
@BeforeEach void setup() {
@@ -34,6 +38,7 @@ class NotificationServiceTest {
ReflectionTestUtils.setField(service, "renderer", new NotificationTemplateRenderer());
ReflectionTestUtils.setField(service, "resolver", resolver);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
ReflectionTestUtils.setField(service, "dispatchTransactions", dispatchTransactions);
}
@Test void createsTaskAndImmutableRecipientSnapshot() {
@@ -63,6 +68,39 @@ class NotificationServiceTest {
assertThat(stored.get(0).getStatus()).isEqualTo("DELIVERED");
}
@Test void returnsWhitespaceCompactedUnicodeBoundedInboxSummaryWithoutFullBody() {
NotificationInbox item = new NotificationInbox();
item.setId(8L); item.setCategory("ORDER"); item.setTitle("状态变化"); item.setStatus("UNREAD");
item.setSentAt(LocalDateTime.now()); item.setBody(" 第一行\n" + "测".repeat(140) + " ");
when(repository.pageInbox(9L, 11L, "MERCHANT", 1, 20, null, null))
.thenReturn(new com.baiyue.common.core.api.PageResult<>(List.of(item), 1, 20, 1, 1));
NotificationSummary summary = service.inbox(9L, 11L, "MERCHANT", 1, 20, null, null).records().get(0);
assertThat(summary.summary()).startsWith("第一行 ");
assertThat(summary.summary().codePointCount(0, summary.summary().length())).isEqualTo(120);
assertThat(summary.summary()).isNotEqualTo(item.getBody());
}
@Test void returnsTaskAndRecipientFailureEvidence() {
NotificationTask task = task(41L, "DEAD");
task.setTaskNo("NT41"); task.setLastError("gateway timeout"); task.setAttemptCount(3);
NotificationRecipient recipient = new NotificationRecipient();
recipient.setId(51L); recipient.setDisplayName("客户成员"); recipient.setAppCode("MERCHANT");
recipient.setStatus("FAILED"); recipient.setFailureReason("gateway timeout");
when(repository.task(41L)).thenReturn(task);
when(repository.recipients(41L)).thenReturn(List.of(recipient));
TaskDetail detail = service.taskDetail(41L);
assertThat(detail.attemptCount()).isEqualTo(3);
assertThat(detail.lastError()).isEqualTo("gateway timeout");
assertThat(detail.recipients()).singleElement().satisfies(value -> {
assertThat(value.status()).isEqualTo("FAILED");
assertThat(value.failureReason()).isEqualTo("gateway timeout");
});
}
@Test void returnsOriginalTaskForRepeatedIdempotencyKey() {
NotificationTask existing = new NotificationTask(); existing.setId(5L); existing.setTaskNo("NT5");
existing.setCategory("ORDER"); existing.setTitle("A"); existing.setStatus("SENT"); existing.setRecipientCount(1);
@@ -151,31 +189,28 @@ class NotificationServiceTest {
}
@Test void recoversOnlyTasksOlderThanConfiguredDispatchTimeout() {
when(repository.recoverTimedOutDispatching(any(), any())).thenReturn(2);
when(repository.recoverTimedOutDispatching(any(), any(), eq(3))).thenReturn(2);
assertThat(service.recoverTimedOutDispatching(300)).isEqualTo(2);
ArgumentCaptor<LocalDateTime> staleBefore = ArgumentCaptor.forClass(LocalDateTime.class);
ArgumentCaptor<LocalDateTime> now = ArgumentCaptor.forClass(LocalDateTime.class);
verify(repository).recoverTimedOutDispatching(staleBefore.capture(), now.capture());
verify(repository).recoverTimedOutDispatching(staleBefore.capture(), now.capture(), eq(3));
assertThat(staleBefore.getValue()).isEqualTo(now.getValue().minusSeconds(300));
}
@Test void onlyOneInstanceCanClaimRecoveredTask() {
when(repository.claimRetrying(41L)).thenReturn(true, false);
NotificationTask task = task(41L, "RETRYING");
when(repository.task(41L)).thenReturn(task);
when(repository.recipients(41L)).thenReturn(List.of());
when(dispatchTransactions.claimRetrying(41L)).thenReturn(true, false);
assertThat(service.dispatchRetrying(41L)).isTrue();
assertThat(service.dispatchRetrying(41L)).isFalse();
verify(repository, times(2)).claimRetrying(41L);
verify(repository, times(1)).saveTask(task);
verify(dispatchTransactions, times(2)).claimRetrying(41L);
verify(dispatchTransactions, times(1)).deliverClaimed(41L);
}
@Test void sentTaskCannotBeClaimedForRecoveryDelivery() {
when(repository.claimRetrying(42L)).thenReturn(false);
when(dispatchTransactions.claimRetrying(42L)).thenReturn(false);
assertThat(service.dispatchRetrying(42L)).isFalse();
@@ -183,6 +218,30 @@ class NotificationServiceTest {
verify(repository, never()).recipients(42L);
}
@Test void persistsScheduledDeliveryFailureAfterCommittedClaim() {
IllegalStateException failure = new IllegalStateException("inbox unavailable");
when(dispatchTransactions.claimScheduled(43L)).thenReturn(true);
doThrow(failure).when(dispatchTransactions).deliverClaimed(43L);
assertThatThrownBy(() -> service.dispatchScheduled(43L)).isSameAs(failure);
verify(dispatchTransactions).recordFailure(43L, failure);
}
@Test void manualRetryResetsAttemptBudgetAndWaitsForScheduler() {
NotificationTask task = task(44L, "DEAD");
task.setAttemptCount(3); task.setLastError("timeout");
when(repository.task(44L)).thenReturn(task);
when(repository.requeueForManualRetry(eq(44L), any())).thenReturn(true);
TaskListItem result = service.retry(44L);
assertThat(result.status()).isEqualTo("RETRYING");
assertThat(task.getAttemptCount()).isZero();
assertThat(task.getLastError()).isNull();
verify(dispatchTransactions, never()).deliverClaimed(anyLong());
}
private NotificationTask task(long id, String status) {
NotificationTask value = new NotificationTask();
value.setId(id); value.setStatus(status); value.setCategory("ORDER"); value.setTitle("A");
@@ -179,11 +179,11 @@ class TenantServiceTest {
}
@Test
void archiveIsIdempotentForDisabledCustomerWithoutCallingAuth() {
IamTenant tenant = merchant(10001L, "DISABLED");
void archiveIsIdempotentForArchivedCustomerWithoutCallingAuth() {
IamTenant tenant = merchant(10001L, "ARCHIVED");
when(repository.findTenantForUpdate(10001L)).thenReturn(tenant);
assertEquals("DISABLED", service.archiveMerchant(10001L).status());
assertEquals("ARCHIVED", service.archiveMerchant(10001L).status());
verify(authClient, never()).archiveTenant(any(), any());
verify(repository, never()).saveTenant(any());
}
@@ -222,6 +222,34 @@ class TenantServiceTest {
assertEquals(ResultCode.CONFLICT, exception.getResultCode());
}
@Test
void archivedCustomerCannotBeReactivated() {
when(repository.findTenant(10001L)).thenReturn(merchant(10001L, "ARCHIVED"));
BizException exception = assertThrows(BizException.class,
() -> service.changeMerchantStatus(10001L, new ChangeTenantStatusRequest("ACTIVE")));
assertEquals(ResultCode.CONFLICT, exception.getResultCode());
verify(repository, never()).saveTenant(any());
}
@Test
void archivedCustomerIsReadOnlyForPlatformAndMerchantProfileUpdates() {
IamTenant archived = merchant(10001L, "ARCHIVED");
when(repository.findTenant(10001L)).thenReturn(archived);
BizException platformFailure = assertThrows(BizException.class, () -> service.updateMerchant(10001L,
new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13800138000", null,
"基础版", null, null, 30, null, null, null, "CNY", null, null)));
BizException merchantFailure = assertThrows(BizException.class, () -> service.update(10001L,
new UpdateProfileRequest("New name", "New legal", "Bob", "Alice",
"13800138000", "alice@example.com")));
assertEquals(ResultCode.CONFLICT, platformFailure.getResultCode());
assertEquals(ResultCode.CONFLICT, merchantFailure.getResultCode());
verify(repository, never()).saveTenant(any());
}
@Test
void merchantProfileCannotReadOrUpdatePlatformTenant() {
IamTenant platform = new IamTenant();
@@ -300,7 +328,7 @@ class TenantServiceTest {
var archived = service.archiveMerchant(10001L);
assertEquals("DISABLED", archived.status());
assertEquals("ARCHIVED", archived.status());
verify(authClient).archiveTenant("internal-token", 10001L);
verify(repository).saveTenant(tenant);
}