优化
This commit is contained in:
@@ -4,12 +4,14 @@ import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
* 身份与权限服务启动入口,负责装配租户、成员、双端 RBAC 和授权快照相关组件。
|
||||
*/
|
||||
@MapperScan("com.baiyue.iam.mapper")
|
||||
@EnableFeignClients(basePackages = "com.baiyue.iam.client")
|
||||
@EnableScheduling
|
||||
@SpringBootApplication(scanBasePackages = "com.baiyue")
|
||||
public class BaiyueIamApplication {
|
||||
public static void main(String[] args) { SpringApplication.run(BaiyueIamApplication.class, args); }
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.baiyue.iam.notification.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.iam.notification.dto.NotificationDtos.*;
|
||||
import com.baiyue.iam.notification.service.NotificationService;
|
||||
import com.baiyue.iam.service.TenantGuard;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController @Validated
|
||||
@RequestMapping("/api/{app}/v1/notifications")
|
||||
public class NotificationInboxController {
|
||||
@Resource private NotificationService service;
|
||||
|
||||
@GetMapping @PreAuthorize("hasAuthority(#app + ':notification:view')")
|
||||
public ApiResponse<PageResult<NotificationSummary>> list(@PathVariable String app,
|
||||
@RequestParam(defaultValue="1") @Min(1) long page,@RequestParam(defaultValue="20") @Min(1) @Max(200) long size,
|
||||
@RequestParam(required=false) @Pattern(regexp="UNREAD|READ") String status,@RequestParam(required=false) String category,
|
||||
@AuthenticationPrincipal AuthenticatedUser user){String code=guard(app,user);return ApiResponse.success(service.inbox(user.tenantId(),user.membershipId(),code,page,size,status,category));}
|
||||
@GetMapping("/unread-count") @PreAuthorize("hasAuthority(#app + ':notification:view')")
|
||||
public ApiResponse<UnreadCountResponse> unread(@PathVariable String app,@AuthenticationPrincipal AuthenticatedUser user){String code=guard(app,user);return ApiResponse.success(service.unread(user.tenantId(),user.membershipId(),code));}
|
||||
@GetMapping("/{id:\\d+}") @PreAuthorize("hasAuthority(#app + ':notification:view')")
|
||||
public ApiResponse<NotificationDetail> detail(@PathVariable String app,@PathVariable Long id,@AuthenticationPrincipal AuthenticatedUser user){String code=guard(app,user);return ApiResponse.success(service.detail(user.tenantId(),user.membershipId(),code,id));}
|
||||
@PutMapping("/{id:\\d+}/read") @PreAuthorize("hasAuthority(#app + ':notification:view')")
|
||||
public ApiResponse<NotificationDetail> read(@PathVariable String app,@PathVariable Long id,@AuthenticationPrincipal AuthenticatedUser user){String code=guard(app,user);return ApiResponse.success(service.read(user.tenantId(),user.membershipId(),code,id));}
|
||||
@PutMapping("/read-all") @PreAuthorize("hasAuthority(#app + ':notification:view')")
|
||||
public ApiResponse<ReadAllResponse> readAll(@PathVariable String app,@AuthenticationPrincipal AuthenticatedUser user){String code=guard(app,user);return ApiResponse.success(service.readAll(user.tenantId(),user.membershipId(),code));}
|
||||
private String guard(String app,AuthenticatedUser user){String code=switch(app){case"platform"->"PLATFORM";case"merchant"->"MERCHANT";default->throw new BizException(ResultCode.BAD_REQUEST,"不支持的应用端");};TenantGuard.tenant(user,code);return code;}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.iam.notification.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.iam.notification.dto.NotificationDtos.*;
|
||||
import com.baiyue.iam.notification.service.NotificationService;
|
||||
import com.baiyue.iam.service.TenantGuard;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController @Validated @RequestMapping("/api/platform/v1")
|
||||
public class PlatformNotificationController {
|
||||
@Resource private NotificationService service;
|
||||
@PostMapping("/notifications") @PreAuthorize("hasAuthority('platform:notification:create')")
|
||||
public ApiResponse<TaskListItem> create(@Valid @RequestBody CreateNotificationRequest request,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.create(request,user.membershipId()));}
|
||||
@GetMapping("/notification-templates") @PreAuthorize("hasAuthority('platform:notification:template:list')")
|
||||
public ApiResponse<PageResult<TemplateResponse>> templates(@RequestParam(defaultValue="1")@Min(1)long page,@RequestParam(defaultValue="20")@Min(1)@Max(200)long size,@RequestParam(required=false)String templateCode,@RequestParam(required=false)String category,@RequestParam(required=false)String title,@RequestParam(required=false)@Pattern(regexp="DRAFT|ACTIVE|DISABLED")String status,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.templates(page,size,templateCode,category,title,status));}
|
||||
@PostMapping("/notification-templates") @PreAuthorize("hasAuthority('platform:notification:template:create')")
|
||||
public ApiResponse<TemplateResponse> createTemplate(@Valid@RequestBody CreateTemplateRequest request,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.createTemplate(request,user.membershipId()));}
|
||||
@PutMapping("/notification-templates/{id:\\d+}") @PreAuthorize("hasAuthority('platform:notification:template:edit')")
|
||||
public ApiResponse<TemplateResponse> updateTemplate(@PathVariable Long id,@Valid@RequestBody UpdateTemplateRequest request,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.updateTemplate(id,request,user.membershipId()));}
|
||||
@PutMapping("/notification-templates/{id:\\d+}/status") @PreAuthorize("hasAuthority('platform:notification:template:status')")
|
||||
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));}
|
||||
@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");}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import jakarta.validation.constraints.Future;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
@@ -35,4 +36,35 @@ public final class NotificationDtos {
|
||||
public record NotificationTaskResponse(Long id, String taskNo, String status, int recipientCount,
|
||||
LocalDateTime scheduledAt, LocalDateTime sentAt,
|
||||
boolean duplicated) { }
|
||||
|
||||
public record UnreadCountResponse(long unreadCount) { }
|
||||
public record ReadAllResponse(long updatedCount) { }
|
||||
|
||||
public record CreateTemplateRequest(
|
||||
@NotBlank @Pattern(regexp = "^[A-Z][A-Z0-9_]{0,63}$") String templateCode,
|
||||
@NotBlank @Size(max = 128) String templateName,
|
||||
@NotBlank @Size(max = 64) String category,
|
||||
@NotBlank @Size(max = 256) String titleTemplate,
|
||||
@NotBlank @Size(max = 10000) String bodyTemplate,
|
||||
@NotNull @Size(max = 32) List<@Pattern(regexp = "^[a-z][a-z0-9_]{0,31}$") String> variableWhitelist) { }
|
||||
|
||||
public record UpdateTemplateRequest(
|
||||
@NotBlank @Size(max = 128) String templateName,
|
||||
@NotBlank @Size(max = 64) String category,
|
||||
@NotBlank @Size(max = 256) String titleTemplate,
|
||||
@NotBlank @Size(max = 10000) String bodyTemplate,
|
||||
@NotNull @Size(max = 32) List<@Pattern(regexp = "^[a-z][a-z0-9_]{0,31}$") String> variableWhitelist,
|
||||
@NotNull Integer version) { }
|
||||
|
||||
public record ChangeTemplateStatusRequest(
|
||||
@NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String targetStatus,
|
||||
@NotNull Integer version) { }
|
||||
|
||||
public record TemplateResponse(Long id, String templateCode, String templateName, String category,
|
||||
String channel, String titleTemplate, String bodyTemplate,
|
||||
List<String> variableWhitelist, String status, Integer version) { }
|
||||
|
||||
public record TaskListItem(Long id, String taskNo, String category, String title, String status,
|
||||
int recipientCount, LocalDateTime scheduledAt, LocalDateTime sentAt,
|
||||
boolean duplicated) { }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.baiyue.iam.notification.entity;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import java.time.LocalDateTime;
|
||||
@Getter @Setter @TableName("iam_notification_event_inbox")
|
||||
public class NotificationEventInbox {
|
||||
@TableId private Long id; private String sourceEventId; private String eventType; private String payloadHash;
|
||||
private Long taskId; private String status; private String lastError; private LocalDateTime processedAt;
|
||||
private LocalDateTime createdAt; private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -15,6 +15,9 @@ public class NotificationTask {
|
||||
private Long tenantId;
|
||||
private Long templateId;
|
||||
private String templateCode;
|
||||
private String category;
|
||||
private String title;
|
||||
private String body;
|
||||
private String recipientType;
|
||||
private String variablesJson;
|
||||
private String idempotencyKey;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.baiyue.iam.notification.mapper;
|
||||
import com.baiyue.iam.notification.entity.NotificationEventInbox;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
@Mapper public interface NotificationEventInboxMapper extends BaseMapper<NotificationEventInbox> { }
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.baiyue.iam.notification.mq;
|
||||
|
||||
import com.baiyue.iam.notification.dto.NotificationDtos.CreateNotificationRequest;
|
||||
import com.baiyue.iam.notification.dto.NotificationDtos.RecipientType;
|
||||
import com.baiyue.iam.notification.service.NotificationService;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
|
||||
import org.apache.rocketmq.spring.core.RocketMQListener;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
@ConditionalOnProperty(name="baiyue.notification.mq-consumer-enabled",havingValue="true")
|
||||
@RocketMQMessageListener(topic=NotificationMqContract.BUSINESS_EVENT_TOPIC,
|
||||
selectorExpression=NotificationMqContract.BUSINESS_EVENT_TAGS,
|
||||
consumerGroup="iam-notification-business-event-consumer")
|
||||
public class NotificationBusinessEventConsumer implements RocketMQListener<String> {
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private NotificationService service;
|
||||
|
||||
@Override public void onMessage(String message){
|
||||
try{
|
||||
NotificationMqContract.BusinessEvent event=objectMapper.readValue(message,NotificationMqContract.BusinessEvent.class);
|
||||
if(event.sourceEventId()==null||event.sourceEventId().isBlank()||event.eventType()==null||event.eventType().isBlank()||event.tenantId()==null)throw new IllegalArgumentException("Missing event identity or customer scope");
|
||||
Map<String,Object> payload=event.payload();
|
||||
String template=required(payload,"templateCode");RecipientType type=RecipientType.valueOf(required(payload,"recipientType"));
|
||||
List<Long> ids=objectMapper.convertValue(payload.getOrDefault("recipientIds",List.of()),new TypeReference<>(){});
|
||||
Map<String,String> variables=objectMapper.convertValue(payload.getOrDefault("variables",Map.of()),new TypeReference<>(){});
|
||||
service.create(new CreateNotificationRequest(event.tenantId(),template,type,ids,(String)payload.get("roleCode"),variables,null,
|
||||
event.sourceEventId()+":"+event.eventType(),event.sourceEventId()),0L);
|
||||
}catch(JsonProcessingException e){throw new IllegalArgumentException("Invalid notification business event",e);}
|
||||
}
|
||||
private String required(Map<String,Object> payload,String key){Object value=payload.get(key);if(value==null||value.toString().isBlank())throw new IllegalArgumentException("Missing "+key);return value.toString();}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.baiyue.iam.notification.mq;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
public final class NotificationMqContract {
|
||||
public static final String NOTIFICATION_TOPIC="baiyue.iam.notification";
|
||||
public static final String BUSINESS_EVENT_TOPIC="baiyue.business.event";
|
||||
public static final String BUSINESS_EVENT_TAGS="order.status.changed||inbound.review.result||finance.fee.review.result||system.announcement";
|
||||
private NotificationMqContract(){}
|
||||
public record BusinessEvent(String eventId,String sourceEventId,Long tenantId,String aggregateType,String aggregateId,String eventType,LocalDateTime occurredAt,Map<String,Object> payload){}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.baiyue.iam.notification.repository;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.iam.notification.entity.*;
|
||||
import com.baiyue.iam.notification.mapper.*;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.util.StringUtils;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public class NotificationRepository {
|
||||
@Resource private NotificationTemplateMapper templates;
|
||||
@Resource private NotificationTaskMapper tasks;
|
||||
@Resource private NotificationRecipientMapper recipients;
|
||||
@Resource private NotificationInboxMapper inbox;
|
||||
|
||||
public NotificationTemplate templateByCode(long tenantId, String code) {
|
||||
return templates.selectOne(new LambdaQueryWrapper<NotificationTemplate>().eq(NotificationTemplate::getTenantId, tenantId)
|
||||
.eq(NotificationTemplate::getTemplateCode, code).last("LIMIT 1"));
|
||||
}
|
||||
public NotificationTemplate template(Long id) { return templates.selectById(id); }
|
||||
public void insertTemplate(NotificationTemplate value) { templates.insert(value); }
|
||||
public void saveTemplate(NotificationTemplate value) { templates.updateById(value); }
|
||||
public PageResult<NotificationTemplate> pageTemplates(long page,long size,String code,String category,String title,String status) {
|
||||
var query=new LambdaQueryWrapper<NotificationTemplate>().eq(NotificationTemplate::getTenantId,0L)
|
||||
.like(StringUtils.hasText(code),NotificationTemplate::getTemplateCode,code)
|
||||
.eq(StringUtils.hasText(category),NotificationTemplate::getCategory,category)
|
||||
.eq(StringUtils.hasText(status),NotificationTemplate::getStatus,status).orderByDesc(NotificationTemplate::getUpdatedAt);
|
||||
if(StringUtils.hasText(title)) query.and(q->q.like(NotificationTemplate::getTemplateName,title).or().like(NotificationTemplate::getTitleTemplate,title));
|
||||
return PageResult.from(templates.selectPage(new Page<>(page,size),query));
|
||||
}
|
||||
public NotificationTask taskByIdempotency(String key) { return tasks.selectOne(new LambdaQueryWrapper<NotificationTask>().eq(NotificationTask::getIdempotencyKey,key).last("LIMIT 1")); }
|
||||
public NotificationTask task(Long id) { return tasks.selectById(id); }
|
||||
public void insertTask(NotificationTask value) { tasks.insert(value); }
|
||||
public void saveTask(NotificationTask value) { tasks.updateById(value); }
|
||||
public PageResult<NotificationTask> pageTasks(long page,long size,String taskNo,String category,String title,String status) {
|
||||
return PageResult.from(tasks.selectPage(new Page<>(page,size),new LambdaQueryWrapper<NotificationTask>()
|
||||
.like(StringUtils.hasText(taskNo),NotificationTask::getTaskNo,taskNo)
|
||||
.eq(StringUtils.hasText(category),NotificationTask::getCategory,category)
|
||||
.like(StringUtils.hasText(title),NotificationTask::getTitle,title)
|
||||
.eq(StringUtils.hasText(status),NotificationTask::getStatus,status)
|
||||
.orderByDesc(NotificationTask::getCreatedAt)));
|
||||
}
|
||||
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 void insertInbox(NotificationInbox value) { inbox.insert(value); }
|
||||
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)
|
||||
.eq(NotificationInbox::getAppCode,app).eq(StringUtils.hasText(status),NotificationInbox::getStatus,status)
|
||||
.eq(StringUtils.hasText(category),NotificationInbox::getCategory,category).orderByDesc(NotificationInbox::getSentAt)));
|
||||
}
|
||||
public NotificationInbox inbox(long tenantId,long membershipId,String app,Long id) { return inbox.selectOne(new LambdaQueryWrapper<NotificationInbox>()
|
||||
.eq(NotificationInbox::getTenantId,tenantId).eq(NotificationInbox::getMembershipId,membershipId).eq(NotificationInbox::getAppCode,app).eq(NotificationInbox::getId,id).last("LIMIT 1")); }
|
||||
public long unread(long tenantId,long membershipId,String app) { return inbox.selectCount(new LambdaQueryWrapper<NotificationInbox>()
|
||||
.eq(NotificationInbox::getTenantId,tenantId).eq(NotificationInbox::getMembershipId,membershipId).eq(NotificationInbox::getAppCode,app).eq(NotificationInbox::getStatus,"UNREAD")); }
|
||||
public void saveInbox(NotificationInbox value) { inbox.updateById(value); }
|
||||
public List<NotificationInbox> unreadItems(long tenantId,long membershipId,String app) { return inbox.selectList(new LambdaQueryWrapper<NotificationInbox>()
|
||||
.eq(NotificationInbox::getTenantId,tenantId).eq(NotificationInbox::getMembershipId,membershipId).eq(NotificationInbox::getAppCode,app).eq(NotificationInbox::getStatus,"UNREAD")); }
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package com.baiyue.iam.notification.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.iam.entity.IamMembership;
|
||||
import com.baiyue.iam.entity.IamUser;
|
||||
import com.baiyue.iam.mapper.IamMembershipMapper;
|
||||
import com.baiyue.iam.mapper.IamMembershipRoleMapper;
|
||||
import com.baiyue.iam.mapper.IamRoleMapper;
|
||||
import com.baiyue.iam.mapper.IamUserMapper;
|
||||
import com.baiyue.iam.notification.dto.NotificationDtos.CreateNotificationRequest;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class NotificationRecipientResolver {
|
||||
@Resource private IamMembershipMapper memberships;
|
||||
@Resource private IamUserMapper users;
|
||||
@Resource private IamRoleMapper roles;
|
||||
@Resource private IamMembershipRoleMapper membershipRoles;
|
||||
|
||||
public List<ResolvedRecipient> resolve(CreateNotificationRequest request) {
|
||||
validateSelector(request);
|
||||
List<IamMembership> selected = switch (request.recipientType()) {
|
||||
case MEMBERSHIP -> memberships.selectList(new LambdaQueryWrapper<IamMembership>()
|
||||
.eq(IamMembership::getTenantId, request.tenantId()).in(IamMembership::getId, request.recipientIds()));
|
||||
case USER -> memberships.selectList(new LambdaQueryWrapper<IamMembership>()
|
||||
.eq(IamMembership::getTenantId, request.tenantId()).in(IamMembership::getUserId, request.recipientIds()));
|
||||
case TENANT_ALL -> memberships.selectList(new LambdaQueryWrapper<IamMembership>()
|
||||
.eq(IamMembership::getTenantId, request.tenantId()));
|
||||
case TENANT_ROLE, PLATFORM_ROLE -> byRole(request);
|
||||
};
|
||||
Map<Long, ResolvedRecipient> unique = new LinkedHashMap<>();
|
||||
selected.stream().filter(item -> "ACTIVE".equals(item.getStatus())).forEach(item -> {
|
||||
IamUser user = users.selectById(item.getUserId());
|
||||
if (user != null && "ACTIVE".equals(user.getStatus())) unique.put(item.getId(), new ResolvedRecipient(
|
||||
item.getTenantId(), item.getId(), item.getUserId(), user.getDisplayName(), item.getTenantId() == 0 ? "PLATFORM" : "MERCHANT"));
|
||||
});
|
||||
if (unique.isEmpty()) fail("NOTIFICATION_RECIPIENT_EMPTY");
|
||||
return List.copyOf(unique.values());
|
||||
}
|
||||
|
||||
private List<IamMembership> byRole(CreateNotificationRequest request) {
|
||||
var role = roles.selectOne(new LambdaQueryWrapper<com.baiyue.iam.entity.IamRole>()
|
||||
.eq(com.baiyue.iam.entity.IamRole::getTenantId, request.tenantId())
|
||||
.eq(com.baiyue.iam.entity.IamRole::getRoleCode, request.roleCode()).eq(com.baiyue.iam.entity.IamRole::getStatus,"ACTIVE").last("LIMIT 1"));
|
||||
if (role == null) fail("NOTIFICATION_RECIPIENT_FORBIDDEN");
|
||||
List<Long> ids = membershipRoles.selectList(new LambdaQueryWrapper<com.baiyue.iam.entity.IamMembershipRole>()
|
||||
.eq(com.baiyue.iam.entity.IamMembershipRole::getTenantId,request.tenantId())
|
||||
.eq(com.baiyue.iam.entity.IamMembershipRole::getRoleId,role.getId())).stream()
|
||||
.map(com.baiyue.iam.entity.IamMembershipRole::getMembershipId).toList();
|
||||
return ids.isEmpty()?List.of():memberships.selectBatchIds(ids);
|
||||
}
|
||||
|
||||
private void validateSelector(CreateNotificationRequest request) {
|
||||
boolean ids = request.recipientIds()!=null&&!request.recipientIds().isEmpty();
|
||||
boolean role = request.roleCode()!=null&&!request.roleCode().isBlank();
|
||||
switch(request.recipientType()) {
|
||||
case USER, MEMBERSHIP -> { if(!ids||role) fail("NOTIFICATION_RECIPIENT_FORBIDDEN"); }
|
||||
case TENANT_ROLE, PLATFORM_ROLE -> { if(ids||!role) fail("NOTIFICATION_RECIPIENT_FORBIDDEN"); }
|
||||
case TENANT_ALL -> { if(ids||role) fail("NOTIFICATION_RECIPIENT_FORBIDDEN"); }
|
||||
}
|
||||
if(request.recipientType()==com.baiyue.iam.notification.dto.NotificationDtos.RecipientType.PLATFORM_ROLE&&request.tenantId()!=0) fail("NOTIFICATION_RECIPIENT_FORBIDDEN");
|
||||
if(request.recipientType()!=com.baiyue.iam.notification.dto.NotificationDtos.RecipientType.PLATFORM_ROLE&&request.tenantId()<0) fail("NOTIFICATION_RECIPIENT_FORBIDDEN");
|
||||
}
|
||||
private void fail(String message){throw new BizException(ResultCode.VALIDATION_FAILED,message);}
|
||||
public record ResolvedRecipient(long tenantId,long membershipId,long userId,String displayName,String appCode){}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.baiyue.iam.notification.service;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
@Component @ConditionalOnProperty(name="baiyue.notification.scheduler-enabled",havingValue="true")
|
||||
public class NotificationScheduler {
|
||||
@Resource private NotificationService service;
|
||||
@Scheduled(fixedDelayString="${baiyue.notification.scheduler-delay-ms:5000}") public void dispatch(){service.dispatchDue();}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package com.baiyue.iam.notification.service;
|
||||
|
||||
import com.baiyue.common.audit.BusinessAudit;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.iam.notification.dto.NotificationDtos.*;
|
||||
import com.baiyue.iam.notification.entity.*;
|
||||
import com.baiyue.iam.notification.repository.NotificationRepository;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class NotificationService {
|
||||
@Resource private NotificationRepository repository;
|
||||
@Resource private NotificationTemplateRenderer renderer;
|
||||
@Resource private NotificationRecipientResolver resolver;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResult<NotificationSummary> inbox(long tenantId, long membershipId, String appCode,
|
||||
long page, long size, String status, String category) {
|
||||
PageResult<NotificationInbox> source = repository.pageInbox(
|
||||
tenantId, membershipId, appCode, page, size, status, category);
|
||||
return new PageResult<>(source.records().stream().map(this::summary).toList(),
|
||||
source.page(), source.size(), source.total(), source.pages());
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public NotificationDetail detail(long tenantId, long membershipId, String appCode, Long id) {
|
||||
return detail(requiredInbox(tenantId, membershipId, appCode, id));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public UnreadCountResponse unread(long tenantId, long membershipId, String appCode) {
|
||||
return new UnreadCountResponse(repository.unread(tenantId, membershipId, appCode));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "NOTIFICATION_READ", resourceType = "NOTIFICATION", resourceId = "#id",
|
||||
description = "读取站内信", captureBefore = false, captureAfter = false)
|
||||
public NotificationDetail read(long tenantId, long membershipId, String appCode, Long id) {
|
||||
NotificationInbox item = requiredInbox(tenantId, membershipId, appCode, id);
|
||||
if ("UNREAD".equals(item.getStatus())) {
|
||||
item.setStatus("READ");
|
||||
item.setReadAt(LocalDateTime.now());
|
||||
repository.saveInbox(item);
|
||||
}
|
||||
return detail(item);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "NOTIFICATION_READ_ALL", resourceType = "NOTIFICATION",
|
||||
description = "全部标记已读", captureBefore = false, captureAfter = false)
|
||||
public ReadAllResponse readAll(long tenantId, long membershipId, String appCode) {
|
||||
List<NotificationInbox> items = repository.unreadItems(tenantId, membershipId, appCode);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
items.forEach(item -> {
|
||||
item.setStatus("READ");
|
||||
item.setReadAt(now);
|
||||
repository.saveInbox(item);
|
||||
});
|
||||
return new ReadAllResponse(items.size());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "NOTIFICATION_TASK_CREATE", resourceType = "NOTIFICATION_TASK",
|
||||
resourceId = "#request.idempotencyKey", description = "创建消息任务", captureAfter = false)
|
||||
public TaskListItem create(CreateNotificationRequest request, long operator) {
|
||||
NotificationTask existing = repository.taskByIdempotency(request.idempotencyKey());
|
||||
if (existing != null) return taskItem(existing, true);
|
||||
NotificationTemplate template = repository.templateByCode(0, request.templateCode());
|
||||
if (template == null) fail(ResultCode.NOT_FOUND, "NOTIFICATION_TEMPLATE_NOT_FOUND");
|
||||
if (!"ACTIVE".equals(template.getStatus())) fail(ResultCode.CONFLICT, "NOTIFICATION_TEMPLATE_INACTIVE");
|
||||
Set<String> whitelist = readWhitelist(template.getVariableWhitelistJson());
|
||||
String title = renderer.render(template.getTitleTemplate(), whitelist, request.variables());
|
||||
String body = renderer.render(template.getBodyTemplate(), whitelist, request.variables());
|
||||
List<NotificationRecipientResolver.ResolvedRecipient> resolved = resolver.resolve(request);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
NotificationTask task = new NotificationTask();
|
||||
task.setId(IdWorker.getId()); task.setTaskNo("NT" + task.getId()); task.setTenantId(request.tenantId());
|
||||
task.setTemplateId(template.getId()); task.setTemplateCode(template.getTemplateCode());
|
||||
task.setCategory(template.getCategory()); task.setTitle(title); task.setBody(body);
|
||||
task.setRecipientType(request.recipientType().name()); task.setVariablesJson(json(request.variables()));
|
||||
task.setIdempotencyKey(request.idempotencyKey()); task.setSourceEventId(request.sourceEventId());
|
||||
task.setStatus(request.scheduledAt() == null ? "DISPATCHING" : "SCHEDULED");
|
||||
task.setRecipientCount(resolved.size()); task.setAttemptCount(0); task.setScheduledAt(request.scheduledAt());
|
||||
task.setCreatedBy(operator); task.setCreatedAt(now); task.setUpdatedAt(now);
|
||||
repository.insertTask(task);
|
||||
snapshot(task, resolved, now);
|
||||
if (request.scheduledAt() == null) deliver(task, now);
|
||||
return taskItem(task, false);
|
||||
}
|
||||
|
||||
private void snapshot(NotificationTask task, List<NotificationRecipientResolver.ResolvedRecipient> resolved,
|
||||
LocalDateTime now) {
|
||||
for (var value : resolved) {
|
||||
NotificationRecipient recipient = new NotificationRecipient();
|
||||
recipient.setId(IdWorker.getId()); recipient.setTaskId(task.getId()); recipient.setTenantId(value.tenantId());
|
||||
recipient.setMembershipId(value.membershipId()); recipient.setUserId(value.userId());
|
||||
recipient.setDisplayName(value.displayName()); recipient.setAppCode(value.appCode());
|
||||
recipient.setChannel("IN_APP"); recipient.setStatus("RESOLVED");
|
||||
recipient.setCreatedAt(now); recipient.setUpdatedAt(now);
|
||||
repository.insertRecipient(recipient);
|
||||
}
|
||||
}
|
||||
|
||||
private void deliver(NotificationTask task, LocalDateTime now) {
|
||||
for (NotificationRecipient recipient : repository.recipients(task.getId())) {
|
||||
if ("DELIVERED".equals(recipient.getStatus())) continue;
|
||||
NotificationInbox item = new NotificationInbox();
|
||||
item.setId(IdWorker.getId()); item.setTaskId(task.getId()); 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);
|
||||
recipient.setStatus("DELIVERED"); recipient.setDeliveredAt(now); repository.saveRecipient(recipient);
|
||||
}
|
||||
task.setStatus("SENT"); task.setSentAt(now); task.setUpdatedAt(now); repository.saveTask(task);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void dispatchDue() {
|
||||
for (NotificationTask task : repository.dueTasks()) {
|
||||
if (!repository.claimScheduled(task.getId())) continue;
|
||||
task.setStatus("DISPATCHING"); deliver(task, LocalDateTime.now());
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResult<TemplateResponse> templates(long page, long size, String code, String category,
|
||||
String title, String status) {
|
||||
PageResult<NotificationTemplate> source = repository.pageTemplates(page, size, code, category, title, status);
|
||||
return new PageResult<>(source.records().stream().map(this::template).toList(),
|
||||
source.page(), source.size(), source.total(), source.pages());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "NOTIFICATION_TEMPLATE_CREATE", resourceType = "NOTIFICATION_TEMPLATE",
|
||||
resourceId = "#request.templateCode", description = "创建消息模板", captureAfter = false)
|
||||
public TemplateResponse createTemplate(CreateTemplateRequest request, long operator) {
|
||||
if (repository.templateByCode(0, request.templateCode()) != null)
|
||||
fail(ResultCode.CONFLICT, "NOTIFICATION_TEMPLATE_CODE_EXISTS");
|
||||
Set<String> whitelist = new LinkedHashSet<>(request.variableWhitelist());
|
||||
renderer.validateTemplate(request.titleTemplate(), request.bodyTemplate(), whitelist);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
NotificationTemplate template = new NotificationTemplate();
|
||||
template.setId(IdWorker.getId()); template.setTenantId(0L); template.setTemplateCode(request.templateCode());
|
||||
template.setTemplateName(request.templateName()); template.setCategory(request.category());
|
||||
template.setChannel("IN_APP"); template.setTitleTemplate(request.titleTemplate());
|
||||
template.setBodyTemplate(request.bodyTemplate()); template.setVariableWhitelistJson(json(whitelist));
|
||||
template.setStatus("DRAFT"); template.setVersion(1); template.setCreatedBy(operator);
|
||||
template.setUpdatedBy(operator); template.setCreatedAt(now); template.setUpdatedAt(now);
|
||||
repository.insertTemplate(template);
|
||||
return template(template);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "NOTIFICATION_TEMPLATE_UPDATE", resourceType = "NOTIFICATION_TEMPLATE",
|
||||
resourceId = "#id", description = "编辑消息模板", captureAfter = false)
|
||||
public TemplateResponse updateTemplate(Long id, UpdateTemplateRequest request, long operator) {
|
||||
NotificationTemplate template = requiredTemplate(id);
|
||||
if (!"DRAFT".equals(template.getStatus()) || !template.getVersion().equals(request.version()))
|
||||
fail(ResultCode.CONFLICT, "NOTIFICATION_INVALID_STATE");
|
||||
Set<String> whitelist = new LinkedHashSet<>(request.variableWhitelist());
|
||||
renderer.validateTemplate(request.titleTemplate(), request.bodyTemplate(), whitelist);
|
||||
template.setTemplateName(request.templateName()); template.setCategory(request.category());
|
||||
template.setTitleTemplate(request.titleTemplate()); template.setBodyTemplate(request.bodyTemplate());
|
||||
template.setVariableWhitelistJson(json(whitelist)); template.setVersion(template.getVersion() + 1);
|
||||
template.setUpdatedBy(operator); repository.saveTemplate(template);
|
||||
return template(template);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "NOTIFICATION_TEMPLATE_STATUS", resourceType = "NOTIFICATION_TEMPLATE",
|
||||
resourceId = "#id", description = "变更消息模板状态", captureAfter = false)
|
||||
public TemplateResponse changeTemplateStatus(Long id, ChangeTemplateStatusRequest request, long operator) {
|
||||
NotificationTemplate template = requiredTemplate(id);
|
||||
boolean valid = "DRAFT".equals(template.getStatus()) && "ACTIVE".equals(request.targetStatus())
|
||||
|| "ACTIVE".equals(template.getStatus()) && "DISABLED".equals(request.targetStatus());
|
||||
if (!valid || !template.getVersion().equals(request.version()))
|
||||
fail(ResultCode.CONFLICT, "NOTIFICATION_INVALID_STATE");
|
||||
template.setStatus(request.targetStatus()); template.setVersion(template.getVersion() + 1);
|
||||
template.setUpdatedBy(operator); repository.saveTemplate(template);
|
||||
return template(template);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResult<TaskListItem> tasks(long page, long size, String taskNo, String category,
|
||||
String title, String status) {
|
||||
PageResult<NotificationTask> source = repository.pageTasks(page, size, taskNo, category, title, status);
|
||||
return new PageResult<>(source.records().stream().map(task -> taskItem(task, false)).toList(),
|
||||
source.page(), source.size(), source.total(), source.pages());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "NOTIFICATION_TASK_CANCEL", resourceType = "NOTIFICATION_TASK",
|
||||
resourceId = "#id", description = "取消消息任务", captureAfter = false)
|
||||
public TaskListItem cancel(Long id) {
|
||||
NotificationTask task = requiredTask(id);
|
||||
if (!Set.of("CREATED", "SCHEDULED").contains(task.getStatus()))
|
||||
fail(ResultCode.CONFLICT, "NOTIFICATION_INVALID_STATE");
|
||||
task.setStatus("CANCELLED"); repository.saveTask(task);
|
||||
return taskItem(task, false);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "NOTIFICATION_TASK_RETRY", resourceType = "NOTIFICATION_TASK",
|
||||
resourceId = "#id", description = "重试消息任务", captureAfter = false)
|
||||
public TaskListItem retry(Long id) {
|
||||
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());
|
||||
return taskItem(task, false);
|
||||
}
|
||||
|
||||
private NotificationInbox requiredInbox(long tenantId, long membershipId, String appCode, Long id) {
|
||||
NotificationInbox value = repository.inbox(tenantId, membershipId, appCode, id);
|
||||
if (value == null) fail(ResultCode.NOT_FOUND, "NOTIFICATION_NOT_FOUND");
|
||||
return value;
|
||||
}
|
||||
private NotificationTemplate requiredTemplate(Long id) {
|
||||
NotificationTemplate value = repository.template(id);
|
||||
if (value == null) fail(ResultCode.NOT_FOUND, "NOTIFICATION_TEMPLATE_NOT_FOUND");
|
||||
return value;
|
||||
}
|
||||
private NotificationTask requiredTask(Long id) {
|
||||
NotificationTask value = repository.task(id);
|
||||
if (value == null) fail(ResultCode.NOT_FOUND, "NOTIFICATION_NOT_FOUND");
|
||||
return value;
|
||||
}
|
||||
private NotificationSummary summary(NotificationInbox value) {
|
||||
return new NotificationSummary(value.getId(), value.getCategory(), value.getTitle(), value.getStatus(),
|
||||
value.getSentAt(), value.getReadAt());
|
||||
}
|
||||
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());
|
||||
}
|
||||
private TemplateResponse template(NotificationTemplate value) {
|
||||
return new TemplateResponse(value.getId(), value.getTemplateCode(), value.getTemplateName(),
|
||||
value.getCategory(), value.getChannel(), value.getTitleTemplate(), value.getBodyTemplate(),
|
||||
List.copyOf(readWhitelist(value.getVariableWhitelistJson())), value.getStatus(), value.getVersion());
|
||||
}
|
||||
private TaskListItem taskItem(NotificationTask value, boolean duplicated) {
|
||||
return new TaskListItem(value.getId(), value.getTaskNo(), value.getCategory(), value.getTitle(),
|
||||
value.getStatus(), value.getRecipientCount(), value.getScheduledAt(), value.getSentAt(), duplicated);
|
||||
}
|
||||
private Set<String> readWhitelist(String value) {
|
||||
try { return objectMapper.readValue(value, new TypeReference<LinkedHashSet<String>>() { }); }
|
||||
catch (JsonProcessingException exception) { throw new IllegalStateException(exception); }
|
||||
}
|
||||
private String json(Object value) {
|
||||
try { return objectMapper.writeValueAsString(value); }
|
||||
catch (JsonProcessingException exception) { throw new IllegalArgumentException(exception); }
|
||||
}
|
||||
private void fail(ResultCode code, String message) { throw new BizException(code, message); }
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package com.baiyue.iam.notification.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Component
|
||||
public class NotificationTemplateRenderer {
|
||||
private static final Pattern VARIABLE = Pattern.compile("\\{\\{([a-z][a-z0-9_]{0,31})}}" );
|
||||
|
||||
public String render(String template, Set<String> whitelist, Map<String, String> variables) {
|
||||
Set<String> used = placeholders(template);
|
||||
if (!whitelist.containsAll(used)) fail("NOTIFICATION_VARIABLE_NOT_ALLOWED");
|
||||
if (!whitelist.containsAll(variables.keySet())) fail("NOTIFICATION_VARIABLE_NOT_ALLOWED");
|
||||
if (!variables.keySet().containsAll(used)) fail("NOTIFICATION_VARIABLE_MISSING");
|
||||
String rendered = template;
|
||||
for (String key : used) rendered = rendered.replace("{{" + key + "}}", variables.get(key));
|
||||
if (rendered.length() > 10_000) fail("NOTIFICATION_TEMPLATE_RENDER_TOO_LONG");
|
||||
return rendered;
|
||||
}
|
||||
|
||||
public void validateTemplate(String title, String body, Set<String> whitelist) {
|
||||
if (whitelist.size() > 32) fail("NOTIFICATION_VARIABLE_NOT_ALLOWED");
|
||||
Set<String> used = new HashSet<>(placeholders(title)); used.addAll(placeholders(body));
|
||||
if (!whitelist.containsAll(used)) fail("NOTIFICATION_VARIABLE_NOT_ALLOWED");
|
||||
}
|
||||
|
||||
private Set<String> placeholders(String value) {
|
||||
Set<String> keys = new HashSet<>(); Matcher matcher = VARIABLE.matcher(value);
|
||||
while (matcher.find()) keys.add(matcher.group(1));
|
||||
return keys;
|
||||
}
|
||||
|
||||
private void fail(String message) { throw new BizException(ResultCode.VALIDATION_FAILED, message); }
|
||||
}
|
||||
@@ -23,6 +23,9 @@ spring:
|
||||
namespace: ${NACOS_NAMESPACE:public}
|
||||
encode: UTF-8
|
||||
baiyue:
|
||||
notification:
|
||||
scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false}
|
||||
mq-consumer-enabled: ${NOTIFICATION_MQ_CONSUMER_ENABLED:false}
|
||||
clients:
|
||||
auth:
|
||||
url: ${AUTH_SERVICE_URL:}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
USE baiyue_iam;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS iam_notification_template (
|
||||
id BIGINT NOT NULL, tenant_id BIGINT NOT NULL DEFAULT 0, template_code VARCHAR(64) NOT NULL,
|
||||
template_name VARCHAR(128) NOT NULL, category VARCHAR(64) NOT NULL, channel VARCHAR(16) NOT NULL DEFAULT 'IN_APP',
|
||||
title_template VARCHAR(256) NOT NULL, body_template VARCHAR(10000) NOT NULL,
|
||||
variable_whitelist_json JSON NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'DRAFT', version INT NOT NULL DEFAULT 1,
|
||||
created_by BIGINT NOT NULL, updated_by BIGINT NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id), UNIQUE KEY uk_notification_template_code (tenant_id, template_code),
|
||||
KEY idx_notification_template_list (tenant_id, status, updated_at)
|
||||
) ENGINE=InnoDB COMMENT='消息模板;首期仅IN_APP';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS iam_notification_task (
|
||||
id BIGINT NOT NULL, task_no VARCHAR(64) NOT NULL, tenant_id BIGINT NOT NULL, template_id BIGINT NOT NULL,
|
||||
template_code VARCHAR(64) NOT NULL, category VARCHAR(64) NOT NULL, title VARCHAR(256) NOT NULL,
|
||||
body VARCHAR(10000) NOT NULL, recipient_type VARCHAR(32) NOT NULL, variables_json JSON NOT NULL,
|
||||
idempotency_key VARCHAR(128) NOT NULL, source_event_id VARCHAR(128) NULL, source_event_type VARCHAR(128) NULL,
|
||||
status VARCHAR(16) NOT NULL, recipient_count INT NOT NULL DEFAULT 0, attempt_count INT NOT NULL DEFAULT 0,
|
||||
scheduled_at DATETIME(3) NULL, sent_at DATETIME(3) NULL, last_error VARCHAR(1000) NULL, created_by BIGINT NOT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id), UNIQUE KEY uk_notification_task_no (task_no), UNIQUE KEY uk_notification_task_idempotency (idempotency_key),
|
||||
UNIQUE KEY uk_notification_source_event (source_event_id, source_event_type), KEY idx_notification_task_dispatch (status, scheduled_at, created_at),
|
||||
KEY idx_notification_task_tenant (tenant_id, created_at)
|
||||
) ENGINE=InnoDB COMMENT='消息任务与幂等状态机';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS iam_notification_recipient (
|
||||
id BIGINT NOT NULL, task_id BIGINT NOT NULL, tenant_id BIGINT NOT NULL, membership_id BIGINT NOT NULL,
|
||||
user_id BIGINT NOT NULL, display_name VARCHAR(128) NOT NULL, app_code VARCHAR(16) NOT NULL, channel VARCHAR(16) NOT NULL DEFAULT 'IN_APP',
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'RESOLVED', failure_reason VARCHAR(1000) NULL, delivered_at DATETIME(3) NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id), UNIQUE KEY uk_notification_recipient (task_id, membership_id, channel),
|
||||
KEY idx_notification_recipient_member (tenant_id, membership_id, created_at)
|
||||
) ENGINE=InnoDB COMMENT='消息接收人不可变快照';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS iam_notification_inbox (
|
||||
id BIGINT NOT NULL, task_id BIGINT NOT NULL, recipient_id BIGINT NOT NULL, tenant_id BIGINT NOT NULL,
|
||||
membership_id BIGINT NOT NULL, app_code VARCHAR(16) NOT NULL, category VARCHAR(64) NOT NULL,
|
||||
title VARCHAR(256) NOT NULL, body VARCHAR(10000) NOT NULL, source_type VARCHAR(128) NULL, source_id VARCHAR(128) NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'UNREAD', sent_at DATETIME(3) NOT NULL, read_at DATETIME(3) NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id), UNIQUE KEY uk_notification_inbox_recipient (recipient_id),
|
||||
KEY idx_notification_inbox_list (tenant_id, membership_id, app_code, status, sent_at),
|
||||
KEY idx_notification_inbox_category (tenant_id, membership_id, app_code, category, sent_at)
|
||||
) ENGINE=InnoDB COMMENT='按客户成员隔离的站内信';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS iam_notification_event_inbox (
|
||||
id BIGINT NOT NULL, source_event_id VARCHAR(128) NOT NULL, event_type VARCHAR(128) NOT NULL,
|
||||
payload_hash CHAR(64) NOT NULL, task_id BIGINT NULL, status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING',
|
||||
last_error VARCHAR(1000) NULL, processed_at DATETIME(3) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id), UNIQUE KEY uk_notification_event (source_event_id, event_type),
|
||||
KEY idx_notification_event_status (status, updated_at)
|
||||
) ENGINE=InnoDB COMMENT='业务事件消费幂等记录';
|
||||
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted)
|
||||
VALUES
|
||||
(5106000,0,'PLATFORM','MENU',0,'platform:notification','消息中心','/notifications',NULL,600,'ACTIVE',0),
|
||||
(5106001,0,'PLATFORM','API',5106000,'platform:notification:view','查看消息','/api/platform/v1/notifications','GET',601,'ACTIVE',0),
|
||||
(5106002,0,'PLATFORM','BUTTON',5106000,'platform:notification:create','发送消息','/api/platform/v1/notifications','POST',602,'ACTIVE',0),
|
||||
(5106003,0,'PLATFORM','API',5106000,'platform:notification:template:list','查看消息模板','/api/platform/v1/notification-templates','GET',603,'ACTIVE',0),
|
||||
(5106004,0,'PLATFORM','BUTTON',5106000,'platform:notification:template:create','新增消息模板','/api/platform/v1/notification-templates','POST',604,'ACTIVE',0),
|
||||
(5106005,0,'PLATFORM','BUTTON',5106000,'platform:notification:template:edit','编辑消息模板','/api/platform/v1/notification-templates/{id}','PUT',605,'ACTIVE',0),
|
||||
(5106006,0,'PLATFORM','BUTTON',5106000,'platform:notification:template:status','变更模板状态','/api/platform/v1/notification-templates/{id}/status','PUT',606,'ACTIVE',0),
|
||||
(5106007,0,'PLATFORM','API',5106000,'platform:notification:task:list','查看消息任务','/api/platform/v1/notification-tasks','GET',607,'ACTIVE',0),
|
||||
(5106008,0,'PLATFORM','BUTTON',5106000,'platform:notification:task:cancel','取消消息任务','/api/platform/v1/notification-tasks/{id}/cancel','PUT',608,'ACTIVE',0),
|
||||
(5106009,0,'PLATFORM','BUTTON',5106000,'platform:notification:task:retry','重试消息任务','/api/platform/v1/notification-tasks/{id}/retry','PUT',609,'ACTIVE',0)
|
||||
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path), http_method=VALUES(http_method), status='ACTIVE', deleted=0;
|
||||
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted)
|
||||
VALUES (5106010, 0, 'MERCHANT', 'API', 0, 'merchant:notification:view', '查看我的消息',
|
||||
'/api/merchant/v1/notifications', 'GET', 900, 'ACTIVE', 0)
|
||||
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path), status='ACTIVE', deleted=0;
|
||||
|
||||
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
|
||||
SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3)
|
||||
FROM iam_permission p JOIN iam_role r ON r.tenant_id=0 AND r.app_code='MERCHANT' AND r.status='ACTIVE'
|
||||
WHERE p.tenant_id=0 AND p.permission_code='merchant:notification:view' AND r.role_code='MERCHANT_OWNER';
|
||||
|
||||
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
|
||||
SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3)
|
||||
FROM iam_permission p JOIN iam_role r ON r.tenant_id=0 AND r.app_code='PLATFORM' AND r.status='ACTIVE'
|
||||
WHERE p.tenant_id=0 AND p.permission_code LIKE 'platform:notification%' AND r.role_code='PLATFORM_ADMIN';
|
||||
@@ -2,112 +2,173 @@ openapi: 3.0.3
|
||||
info:
|
||||
title: IAM Notification Center API
|
||||
version: 1.0.0
|
||||
description: First release is in-app notifications only. There is no clear-read/delete operation in this release.
|
||||
servers:
|
||||
- url: /api
|
||||
description: First release supports IN_APP only and does not support deleting or clearing read messages.
|
||||
servers: [{url: /api}]
|
||||
tags:
|
||||
- name: Notification inbox
|
||||
- name: Platform notification administration
|
||||
- {name: Notification inbox}
|
||||
- {name: Platform notification administration}
|
||||
paths:
|
||||
/{app}/v1/notifications:
|
||||
get:
|
||||
tags: [Notification inbox]
|
||||
summary: List current user's inbox
|
||||
parameters:
|
||||
- {name: app, in: path, required: true, schema: {type: string, enum: [platform, merchant]}}
|
||||
- {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}}
|
||||
- {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 20}}
|
||||
- {$ref: '#/components/parameters/AppPath'}
|
||||
- {$ref: '#/components/parameters/Page'}
|
||||
- {$ref: '#/components/parameters/Size'}
|
||||
- {name: status, in: query, schema: {type: string, enum: [UNREAD, READ]}}
|
||||
- {name: category, in: query, schema: {type: string, maxLength: 64}}
|
||||
responses:
|
||||
'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/InboxPageResponse'}}}}
|
||||
responses: {'200': {$ref: '#/components/responses/InboxPage'}}
|
||||
/{app}/v1/notifications/unread-count:
|
||||
get:
|
||||
tags: [Notification inbox]
|
||||
summary: Count current user's unread inbox messages
|
||||
parameters:
|
||||
- {name: app, in: path, required: true, schema: {type: string, enum: [platform, merchant]}}
|
||||
responses:
|
||||
'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/UnreadCountResponse'}}}}
|
||||
summary: Count current user's unread messages
|
||||
parameters: [{$ref: '#/components/parameters/AppPath'}]
|
||||
responses: {'200': {$ref: '#/components/responses/UnreadCount'}}
|
||||
/{app}/v1/notifications/{id}:
|
||||
get:
|
||||
tags: [Notification inbox]
|
||||
summary: Get one inbox message
|
||||
parameters:
|
||||
- {name: app, in: path, required: true, schema: {type: string, enum: [platform, merchant]}}
|
||||
- {name: id, in: path, required: true, schema: {type: integer, format: int64}}
|
||||
responses:
|
||||
'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/InboxDetailResponse'}}}}
|
||||
parameters: [{$ref: '#/components/parameters/AppPath'}, {$ref: '#/components/parameters/IdPath'}]
|
||||
responses: {'200': {$ref: '#/components/responses/InboxDetail'}}
|
||||
/{app}/v1/notifications/{id}/read:
|
||||
put:
|
||||
tags: [Notification inbox]
|
||||
summary: Mark one message as read (idempotent)
|
||||
parameters:
|
||||
- {name: app, in: path, required: true, schema: {type: string, enum: [platform, merchant]}}
|
||||
- {name: id, in: path, required: true, schema: {type: integer, format: int64}}
|
||||
responses:
|
||||
'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/InboxDetailResponse'}}}}
|
||||
parameters: [{$ref: '#/components/parameters/AppPath'}, {$ref: '#/components/parameters/IdPath'}]
|
||||
responses: {'200': {$ref: '#/components/responses/InboxDetail'}}
|
||||
/{app}/v1/notifications/read-all:
|
||||
put:
|
||||
tags: [Notification inbox]
|
||||
summary: Mark all current-user messages as read (idempotent)
|
||||
parameters:
|
||||
- {name: app, in: path, required: true, schema: {type: string, enum: [platform, merchant]}}
|
||||
responses:
|
||||
'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/ReadAllResponse'}}}}
|
||||
parameters: [{$ref: '#/components/parameters/AppPath'}]
|
||||
responses: {'200': {$ref: '#/components/responses/ReadAll'}}
|
||||
/platform/v1/notifications:
|
||||
post:
|
||||
tags: [Platform notification administration]
|
||||
summary: Create an in-app notification task
|
||||
responses:
|
||||
'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TaskResponse'}}}}
|
||||
summary: Create an IN_APP notification task
|
||||
requestBody: {$ref: '#/components/requestBodies/CreateNotification'}
|
||||
responses: {'200': {$ref: '#/components/responses/TaskDetail'}}
|
||||
/platform/v1/notification-templates:
|
||||
get:
|
||||
tags: [Platform notification administration]
|
||||
summary: List templates
|
||||
parameters: [{name: page, in: query, schema: {type: integer, minimum: 1, default: 1}}, {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 20}}, {name: status, in: query, schema: {type: string, enum: [DRAFT, ACTIVE, DISABLED]}}]
|
||||
responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TemplatePageResponse'}}}}}
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/Page'}
|
||||
- {$ref: '#/components/parameters/Size'}
|
||||
- {name: templateCode, in: query, schema: {type: string, maxLength: 64}}
|
||||
- {name: category, in: query, schema: {type: string, maxLength: 64}}
|
||||
- {name: title, in: query, description: Fuzzy match template name or title template, schema: {type: string, maxLength: 128}}
|
||||
- {name: status, in: query, schema: {type: string, enum: [DRAFT, ACTIVE, DISABLED]}}
|
||||
responses: {'200': {$ref: '#/components/responses/TemplatePage'}}
|
||||
post:
|
||||
tags: [Platform notification administration]
|
||||
summary: Create a DRAFT template
|
||||
responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TemplateResponse'}}}}}
|
||||
requestBody: {$ref: '#/components/requestBodies/CreateTemplate'}
|
||||
responses: {'200': {$ref: '#/components/responses/TemplateDetail'}}
|
||||
/platform/v1/notification-templates/{id}:
|
||||
put:
|
||||
tags: [Platform notification administration]
|
||||
summary: Edit a DRAFT template only
|
||||
responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TemplateResponse'}}}}}
|
||||
parameters: [{$ref: '#/components/parameters/IdPath'}]
|
||||
requestBody: {$ref: '#/components/requestBodies/UpdateTemplate'}
|
||||
responses: {'200': {$ref: '#/components/responses/TemplateDetail'}}
|
||||
/platform/v1/notification-templates/{id}/status:
|
||||
put:
|
||||
tags: [Platform notification administration]
|
||||
summary: Activate or disable a template; only DRAFT->ACTIVE or ACTIVE->DISABLED
|
||||
responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TemplateResponse'}}}}}
|
||||
summary: Change template status
|
||||
description: Only DRAFT to ACTIVE and ACTIVE to DISABLED are valid. DISABLED cannot return to ACTIVE; copy it to a new DRAFT instead.
|
||||
parameters: [{$ref: '#/components/parameters/IdPath'}]
|
||||
requestBody: {$ref: '#/components/requestBodies/ChangeTemplateStatus'}
|
||||
responses: {'200': {$ref: '#/components/responses/TemplateDetail'}}
|
||||
/platform/v1/notification-tasks:
|
||||
get:
|
||||
tags: [Platform notification administration]
|
||||
summary: List tasks
|
||||
parameters: [{name: page, in: query, schema: {type: integer, minimum: 1, default: 1}}, {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 20}}, {name: status, in: query, schema: {type: string, enum: [CREATED, SCHEDULED, DISPATCHING, RETRYING, SENT, DEAD, CANCELLED]}}]
|
||||
responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TaskPageResponse'}}}}}
|
||||
summary: List notification tasks
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/Page'}
|
||||
- {$ref: '#/components/parameters/Size'}
|
||||
- {name: taskNo, in: query, schema: {type: string, maxLength: 64}}
|
||||
- {name: category, in: query, schema: {type: string, maxLength: 64}}
|
||||
- {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}/cancel:
|
||||
put:
|
||||
tags: [Platform notification administration]
|
||||
summary: Cancel CREATED or SCHEDULED task
|
||||
responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TaskResponse'}}}}}
|
||||
summary: Cancel a CREATED or SCHEDULED task
|
||||
parameters: [{$ref: '#/components/parameters/IdPath'}]
|
||||
responses: {'200': {$ref: '#/components/responses/TaskDetail'}}
|
||||
/platform/v1/notification-tasks/{id}/retry:
|
||||
put:
|
||||
tags: [Platform notification administration]
|
||||
summary: Retry RETRYING or DEAD task
|
||||
responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TaskResponse'}}}}}
|
||||
summary: Retry a RETRYING or DEAD task
|
||||
parameters: [{$ref: '#/components/parameters/IdPath'}]
|
||||
responses: {'200': {$ref: '#/components/responses/TaskDetail'}}
|
||||
components:
|
||||
parameters:
|
||||
AppPath: {name: app, in: path, required: true, schema: {type: string, enum: [platform, merchant]}}
|
||||
IdPath: {name: id, in: path, required: true, schema: {type: integer, format: int64}}
|
||||
Page: {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}}
|
||||
Size: {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 20}}
|
||||
requestBodies:
|
||||
CreateNotification: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CreateNotificationRequest'}}}}
|
||||
CreateTemplate: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CreateTemplateRequest'}}}}
|
||||
UpdateTemplate: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/UpdateTemplateRequest'}}}}
|
||||
ChangeTemplateStatus: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ChangeTemplateStatusRequest'}}}}
|
||||
responses:
|
||||
InboxPage: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/InboxPageResponse'}}}}
|
||||
InboxDetail: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/InboxDetailResponse'}}}}
|
||||
UnreadCount: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/UnreadCountResponse'}}}}
|
||||
ReadAll: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/ReadAllResponse'}}}}
|
||||
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'}}}}
|
||||
TaskDetail: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TaskDetailResponse'}}}}
|
||||
schemas:
|
||||
PageMeta: {type: object, required: [page, size, total, pages], properties: {page: {type: integer, format: int64}, size: {type: integer, format: int64}, total: {type: integer, format: int64}, pages: {type: integer, format: int64}}}
|
||||
InboxItem: {type: object, 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}}}
|
||||
InboxDetail: {allOf: [{$ref: '#/components/schemas/InboxItem'}, {type: object, properties: {body: {type: string}, sourceType: {type: string}, sourceId: {type: string}}}]}
|
||||
InboxPageResponse: {type: object, properties: {code: {type: string, example: '200'}, data: {type: object, properties: {records: {type: array, items: {$ref: '#/components/schemas/InboxItem'}}, page: {type: integer}, size: {type: integer}, total: {type: integer}, pages: {type: integer}}}}}
|
||||
InboxDetailResponse: {type: object, properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/InboxDetail'}}}
|
||||
UnreadCountResponse: {type: object, properties: {code: {type: string, example: '200'}, data: {type: object, properties: {unreadCount: {type: integer, format: int64}}}}}
|
||||
ReadAllResponse: {type: object, properties: {code: {type: string, example: '200'}, data: {type: object, properties: {updatedCount: {type: integer, format: int64}}}}}
|
||||
CreateNotificationRequest: {type: object, required: [tenantId, templateCode, recipientType, variables, idempotencyKey], properties: {tenantId: {type: integer, format: int64, minimum: 0}, templateCode: {type: string, maxLength: 64}, recipientType: {type: string, enum: [USER, MEMBERSHIP, TENANT_ROLE, TENANT_ALL, PLATFORM_ROLE]}, recipientIds: {type: array, maxItems: 1000, items: {type: integer, format: int64}}, roleCode: {type: string, maxLength: 64}, variables: {type: object, additionalProperties: {type: string}}, scheduledAt: {type: string, format: date-time, nullable: true}, idempotencyKey: {type: string, minLength: 8, maxLength: 128}, sourceEventId: {type: string, maxLength: 128, nullable: true}}}
|
||||
TemplateResponse: {type: object, 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}}}
|
||||
TemplatePageResponse: {type: object, properties: {code: {type: string, example: '200'}, data: {type: object, properties: {records: {type: array, items: {$ref: '#/components/schemas/TemplateResponse'}}, page: {type: integer}, size: {type: integer}, total: {type: integer}, pages: {type: integer}}}}}
|
||||
TaskResponse: {type: object, properties: {id: {type: integer, format: int64}, taskNo: {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}}}
|
||||
TaskPageResponse: {type: object, properties: {code: {type: string, example: '200'}, data: {type: object, properties: {records: {type: array, items: {$ref: '#/components/schemas/TaskResponse'}}, page: {type: integer}, size: {type: integer}, total: {type: integer}, pages: {type: integer}}}}}
|
||||
CreateNotificationRequest:
|
||||
type: object
|
||||
required: [tenantId, templateCode, recipientType, variables, idempotencyKey]
|
||||
description: tenantId is a backend compatibility field. 0 means platform scope and a positive value is the customer (enterprise) ID; UI copy must use customer or enterprise, never tenant.
|
||||
properties:
|
||||
tenantId: {type: integer, format: int64, minimum: 0}
|
||||
templateCode: {type: string, maxLength: 64}
|
||||
recipientType: {type: string, enum: [USER, MEMBERSHIP, TENANT_ROLE, TENANT_ALL, PLATFORM_ROLE]}
|
||||
recipientIds: {type: array, maxItems: 1000, items: {type: integer, format: int64}, description: Required for USER or MEMBERSHIP; forbidden for role/all recipient types.}
|
||||
roleCode: {type: string, maxLength: 64, description: Required for TENANT_ROLE or PLATFORM_ROLE; forbidden otherwise.}
|
||||
variables: {type: object, maxProperties: 32, additionalProperties: {type: string, maxLength: 2000}}
|
||||
scheduledAt: {type: string, format: date-time, nullable: true}
|
||||
idempotencyKey: {type: string, minLength: 8, maxLength: 128}
|
||||
sourceEventId: {type: string, maxLength: 128, nullable: true}
|
||||
allOf:
|
||||
- if: {properties: {recipientType: {enum: [USER, MEMBERSHIP]}}}
|
||||
then: {required: [recipientIds]}
|
||||
- if: {properties: {recipientType: {enum: [TENANT_ROLE, PLATFORM_ROLE]}}}
|
||||
then: {required: [roleCode]}
|
||||
CreateTemplateRequest:
|
||||
type: object
|
||||
required: [templateCode, templateName, category, titleTemplate, bodyTemplate, variableWhitelist]
|
||||
properties: {templateCode: {type: string, pattern: '^[A-Z][A-Z0-9_]{0,63}$'}, templateName: {type: string, maxLength: 128}, category: {type: string, maxLength: 64}, titleTemplate: {type: string, maxLength: 256}, bodyTemplate: {type: string, maxLength: 10000}, variableWhitelist: {type: array, maxItems: 32, uniqueItems: true, items: {type: string, pattern: '^[a-z][a-z0-9_]{0,31}$'}}}
|
||||
UpdateTemplateRequest:
|
||||
type: object
|
||||
required: [templateName, category, titleTemplate, bodyTemplate, variableWhitelist, version]
|
||||
properties: {templateName: {type: string, maxLength: 128}, category: {type: string, maxLength: 64}, titleTemplate: {type: string, maxLength: 256}, bodyTemplate: {type: string, maxLength: 10000}, variableWhitelist: {type: array, maxItems: 32, uniqueItems: true, items: {type: string, pattern: '^[a-z][a-z0-9_]{0,31}$'}}, version: {type: integer, minimum: 1}}
|
||||
ChangeTemplateStatusRequest:
|
||||
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}}}
|
||||
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}}}
|
||||
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}}}
|
||||
InboxPageResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/InboxPageData'}}}
|
||||
InboxDetailResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/InboxDetail'}}}
|
||||
UnreadCountResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {type: object, required: [unreadCount], properties: {unreadCount: {type: integer, format: int64}}}}}
|
||||
ReadAllResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {type: object, required: [updatedCount], properties: {updatedCount: {type: integer, format: int64}}}}}
|
||||
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'}}}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.baiyue.iam.notification;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class NotificationOpenApiContractTest {
|
||||
@Test
|
||||
void parsesAndExposesFrozenManagementAndInboxContracts() throws IOException {
|
||||
String document;
|
||||
try (var stream = getClass().getResourceAsStream("/openapi/notification-v1.yaml")) {
|
||||
assertThat(stream).isNotNull();
|
||||
document = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
Map<String, Object> api = new Yaml().load(document);
|
||||
assertThat(api).containsEntry("openapi", "3.0.3");
|
||||
Map<String, Object> paths = map(api.get("paths"));
|
||||
assertThat(paths).containsKeys(
|
||||
"/{app}/v1/notifications", "/{app}/v1/notifications/unread-count",
|
||||
"/{app}/v1/notifications/{id}", "/{app}/v1/notifications/{id}/read",
|
||||
"/{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}/cancel", "/platform/v1/notification-tasks/{id}/retry");
|
||||
assertThat(paths).doesNotContainKey("/merchant/v1/notifications:create");
|
||||
|
||||
assertRequestBody(paths, "/platform/v1/notifications", "post");
|
||||
assertRequestBody(paths, "/platform/v1/notification-templates", "post");
|
||||
assertRequestBody(paths, "/platform/v1/notification-templates/{id}", "put");
|
||||
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}/cancel",
|
||||
"/platform/v1/notification-tasks/{id}/retry")) {
|
||||
Map<String, Object> operation = map(map(paths.get(path)).values().iterator().next());
|
||||
assertThat(list(operation.get("parameters"))).anySatisfy(value ->
|
||||
assertThat(map(value)).containsEntry("$ref", "#/components/parameters/IdPath"));
|
||||
}
|
||||
|
||||
Map<String, Object> components = map(api.get("components"));
|
||||
Map<String, Object> schemas = map(components.get("schemas"));
|
||||
assertThat(schemas).containsKeys("CreateNotificationRequest", "CreateTemplateRequest",
|
||||
"UpdateTemplateRequest", "ChangeTemplateStatusRequest", "InboxPageResponse",
|
||||
"InboxDetailResponse", "UnreadCountResponse", "ReadAllResponse",
|
||||
"TemplateItem", "TemplateDetailResponse", "TaskItem", "TaskDetailResponse");
|
||||
for (String schema : List.of("CreateNotificationRequest", "CreateTemplateRequest",
|
||||
"UpdateTemplateRequest", "ChangeTemplateStatusRequest", "InboxItem", "TemplateItem",
|
||||
"TaskItem", "InboxPageResponse", "TemplateDetailResponse", "TaskDetailResponse")) {
|
||||
assertThat(list(map(schemas.get(schema)).get("required"))).isNotEmpty();
|
||||
}
|
||||
assertThat(document).contains("tenantId is a backend compatibility field")
|
||||
.contains("DISABLED cannot return to ACTIVE")
|
||||
.contains("does not support deleting or clearing read messages")
|
||||
.contains("name: taskNo").contains("name: templateCode").contains("name: title");
|
||||
}
|
||||
|
||||
private void assertRequestBody(Map<String, Object> paths, String path, String method) {
|
||||
assertThat(map(map(paths.get(path)).get(method))).containsKey("requestBody");
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> map(Object value) { return (Map<String, Object>) value; }
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<Object> list(Object value) { return (List<Object>) value; }
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.baiyue.iam.notification;
|
||||
|
||||
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.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.NotificationService;
|
||||
import com.baiyue.iam.notification.service.NotificationTemplateRenderer;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class NotificationServiceTest {
|
||||
private final NotificationRepository repository = mock(NotificationRepository.class);
|
||||
private final NotificationRecipientResolver resolver = mock(NotificationRecipientResolver.class);
|
||||
private final NotificationService service = new NotificationService();
|
||||
|
||||
@BeforeEach void setup() {
|
||||
ReflectionTestUtils.setField(service, "repository", repository);
|
||||
ReflectionTestUtils.setField(service, "renderer", new NotificationTemplateRenderer());
|
||||
ReflectionTestUtils.setField(service, "resolver", resolver);
|
||||
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
|
||||
}
|
||||
|
||||
@Test void createsTaskAndImmutableRecipientSnapshot() {
|
||||
List<NotificationRecipient> stored = new ArrayList<>();
|
||||
doAnswer(call -> { stored.add(call.getArgument(0)); return null; }).when(repository).insertRecipient(any());
|
||||
when(repository.recipients(anyLong())).thenAnswer(call -> List.copyOf(stored));
|
||||
NotificationTemplate template = activeTemplate();
|
||||
when(repository.templateByCode(0, "ORDER_STATUS_CHANGED")).thenReturn(template);
|
||||
when(resolver.resolve(any())).thenReturn(List.of(
|
||||
new NotificationRecipientResolver.ResolvedRecipient(9, 11, 12, "User", "MERCHANT")));
|
||||
|
||||
var request = new CreateNotificationRequest(9L, "ORDER_STATUS_CHANGED", RecipientType.MEMBERSHIP,
|
||||
List.of(11L), null, Map.of("order_no", "A", "status", "READY"), null, "idem-key-1", null);
|
||||
TaskListItem result = service.create(request, 99);
|
||||
|
||||
assertThat(result.status()).isEqualTo("SENT");
|
||||
verify(repository).insertTask(any());
|
||||
verify(repository).insertRecipient(any());
|
||||
verify(repository).insertInbox(any());
|
||||
ArgumentCaptor<NotificationTask> task = ArgumentCaptor.forClass(NotificationTask.class);
|
||||
verify(repository).saveTask(task.capture());
|
||||
assertThat(task.getValue().getTitle()).isEqualTo("A");
|
||||
assertThat(stored).hasSize(1);
|
||||
assertThat(stored.get(0).getStatus()).isEqualTo("DELIVERED");
|
||||
}
|
||||
|
||||
@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);
|
||||
when(repository.taskByIdempotency("idem-key-1")).thenReturn(existing);
|
||||
var request = new CreateNotificationRequest(9L, "ORDER_STATUS_CHANGED", RecipientType.MEMBERSHIP,
|
||||
List.of(11L), null, Map.of(), null, "idem-key-1", null);
|
||||
assertThat(service.create(request, 99).duplicated()).isTrue();
|
||||
verify(repository, never()).insertTask(any());
|
||||
}
|
||||
|
||||
@Test void disabledTemplateCannotReturnToActive() {
|
||||
NotificationTemplate template = new NotificationTemplate(); template.setId(1L);
|
||||
template.setStatus("DISABLED"); template.setVersion(3);
|
||||
when(repository.template(1L)).thenReturn(template);
|
||||
assertThatThrownBy(() -> service.changeTemplateStatus(1L,
|
||||
new ChangeTemplateStatusRequest("ACTIVE", 3), 9))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("NOTIFICATION_INVALID_STATE");
|
||||
}
|
||||
|
||||
private NotificationTemplate activeTemplate() {
|
||||
NotificationTemplate value = new NotificationTemplate(); value.setId(1L);
|
||||
value.setTemplateCode("ORDER_STATUS_CHANGED"); value.setCategory("ORDER");
|
||||
value.setTitleTemplate("{{order_no}}"); value.setBodyTemplate("{{status}}");
|
||||
value.setVariableWhitelistJson("[\"order_no\",\"status\"]"); value.setStatus("ACTIVE");
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.baiyue.iam.notification;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
class NotificationSqlContractTest {
|
||||
@Test void definesTablesIsolationIdempotencyAndPermissions() throws Exception {String sql;try(var in=getClass().getResourceAsStream("/db/notification-center.sql")){assertThat(in).isNotNull();sql=new String(in.readAllBytes(),StandardCharsets.UTF_8);}assertThat(sql).contains("iam_notification_template","iam_notification_task","iam_notification_recipient","iam_notification_inbox","iam_notification_event_inbox","uk_notification_task_idempotency","uk_notification_source_event","uk_notification_recipient","idx_notification_inbox_list","platform:notification:template:status","platform:notification:task:retry","merchant:notification:view");}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.baiyue.iam.notification;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.iam.notification.service.NotificationTemplateRenderer;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
class NotificationTemplateRendererTest {
|
||||
private final NotificationTemplateRenderer renderer=new NotificationTemplateRenderer();
|
||||
@Test void rendersOnlyWhitelistedVariables(){assertThat(renderer.render("Order {{order_no}} is {{status}}",Set.of("order_no","status"),Map.of("order_no","A1","status","ready"))).isEqualTo("Order A1 is ready");}
|
||||
@Test void rejectsMissingAndUnknownVariables(){assertThatThrownBy(()->renderer.render("{{order_no}}",Set.of("order_no"),Map.of())).isInstanceOf(BizException.class).hasMessageContaining("NOTIFICATION_VARIABLE_MISSING");assertThatThrownBy(()->renderer.render("ok",Set.of(),Map.of("secret","x"))).isInstanceOf(BizException.class).hasMessageContaining("NOTIFICATION_VARIABLE_NOT_ALLOWED");}
|
||||
}
|
||||
Reference in New Issue
Block a user