优化
This commit is contained in:
@@ -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));
|
||||
|
||||
+2
@@ -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);
|
||||
}
|
||||
|
||||
+35
-13
@@ -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)
|
||||
|
||||
+74
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 '统一社会信用代码',
|
||||
|
||||
@@ -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'}}}
|
||||
|
||||
Reference in New Issue
Block a user