This commit is contained in:
2026-08-12 18:49:46 +08:00
parent 025467f9ed
commit f2f959e8a5
21 changed files with 1027 additions and 57 deletions
@@ -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); }
@@ -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;}
}
@@ -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> { }
@@ -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")); }
}
@@ -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); }
}
@@ -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); }
}
+3
View File
@@ -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");}
}