diff --git a/iam/src/main/java/com/baiyue/iam/BaiyueIamApplication.java b/iam/src/main/java/com/baiyue/iam/BaiyueIamApplication.java index 0a42654e5..daef7c591 100644 --- a/iam/src/main/java/com/baiyue/iam/BaiyueIamApplication.java +++ b/iam/src/main/java/com/baiyue/iam/BaiyueIamApplication.java @@ -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); } diff --git a/iam/src/main/java/com/baiyue/iam/notification/controller/NotificationInboxController.java b/iam/src/main/java/com/baiyue/iam/notification/controller/NotificationInboxController.java new file mode 100644 index 000000000..87d1cbdc6 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/controller/NotificationInboxController.java @@ -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> 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 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 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 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 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;} +} diff --git a/iam/src/main/java/com/baiyue/iam/notification/controller/PlatformNotificationController.java b/iam/src/main/java/com/baiyue/iam/notification/controller/PlatformNotificationController.java new file mode 100644 index 000000000..7e33c6fba --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/controller/PlatformNotificationController.java @@ -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 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> 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 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 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 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> 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 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 retry(@PathVariable Long id,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.retry(id));} + private void platform(AuthenticatedUser user){TenantGuard.tenant(user,"PLATFORM");} +} diff --git a/iam/src/main/java/com/baiyue/iam/notification/dto/NotificationDtos.java b/iam/src/main/java/com/baiyue/iam/notification/dto/NotificationDtos.java index 5f51e8735..fec6cf3a0 100644 --- a/iam/src/main/java/com/baiyue/iam/notification/dto/NotificationDtos.java +++ b/iam/src/main/java/com/baiyue/iam/notification/dto/NotificationDtos.java @@ -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 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) { } } diff --git a/iam/src/main/java/com/baiyue/iam/notification/entity/NotificationEventInbox.java b/iam/src/main/java/com/baiyue/iam/notification/entity/NotificationEventInbox.java new file mode 100644 index 000000000..5cc9ed414 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/entity/NotificationEventInbox.java @@ -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; +} diff --git a/iam/src/main/java/com/baiyue/iam/notification/entity/NotificationTask.java b/iam/src/main/java/com/baiyue/iam/notification/entity/NotificationTask.java index 0dfd51b06..44488ff5a 100644 --- a/iam/src/main/java/com/baiyue/iam/notification/entity/NotificationTask.java +++ b/iam/src/main/java/com/baiyue/iam/notification/entity/NotificationTask.java @@ -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; diff --git a/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationEventInboxMapper.java b/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationEventInboxMapper.java new file mode 100644 index 000000000..d604cf896 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationEventInboxMapper.java @@ -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 { } diff --git a/iam/src/main/java/com/baiyue/iam/notification/mq/NotificationBusinessEventConsumer.java b/iam/src/main/java/com/baiyue/iam/notification/mq/NotificationBusinessEventConsumer.java new file mode 100644 index 000000000..0f78b981d --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/mq/NotificationBusinessEventConsumer.java @@ -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 { + @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 payload=event.payload(); + String template=required(payload,"templateCode");RecipientType type=RecipientType.valueOf(required(payload,"recipientType")); + List ids=objectMapper.convertValue(payload.getOrDefault("recipientIds",List.of()),new TypeReference<>(){}); + Map 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 payload,String key){Object value=payload.get(key);if(value==null||value.toString().isBlank())throw new IllegalArgumentException("Missing "+key);return value.toString();} +} diff --git a/iam/src/main/java/com/baiyue/iam/notification/mq/NotificationMqContract.java b/iam/src/main/java/com/baiyue/iam/notification/mq/NotificationMqContract.java new file mode 100644 index 000000000..561576eda --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/mq/NotificationMqContract.java @@ -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 payload){} +} diff --git a/iam/src/main/java/com/baiyue/iam/notification/repository/NotificationRepository.java b/iam/src/main/java/com/baiyue/iam/notification/repository/NotificationRepository.java new file mode 100644 index 000000000..37c5a3293 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/repository/NotificationRepository.java @@ -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().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 pageTemplates(long page,long size,String code,String category,String title,String status) { + var query=new LambdaQueryWrapper().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().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 pageTasks(long page,long size,String taskNo,String category,String title,String status) { + return PageResult.from(tasks.selectPage(new Page<>(page,size),new LambdaQueryWrapper() + .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 recipients(Long taskId) { return recipients.selectList(new LambdaQueryWrapper().eq(NotificationRecipient::getTaskId,taskId)); } + public void saveRecipient(NotificationRecipient value) { recipients.updateById(value); } + public List dueTasks() { return tasks.selectList(new LambdaQueryWrapper() + .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() + .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 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() + .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() + .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() + .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 unreadItems(long tenantId,long membershipId,String app) { return inbox.selectList(new LambdaQueryWrapper() + .eq(NotificationInbox::getTenantId,tenantId).eq(NotificationInbox::getMembershipId,membershipId).eq(NotificationInbox::getAppCode,app).eq(NotificationInbox::getStatus,"UNREAD")); } +} diff --git a/iam/src/main/java/com/baiyue/iam/notification/service/NotificationRecipientResolver.java b/iam/src/main/java/com/baiyue/iam/notification/service/NotificationRecipientResolver.java new file mode 100644 index 000000000..eafdadcc5 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/service/NotificationRecipientResolver.java @@ -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 resolve(CreateNotificationRequest request) { + validateSelector(request); + List selected = switch (request.recipientType()) { + case MEMBERSHIP -> memberships.selectList(new LambdaQueryWrapper() + .eq(IamMembership::getTenantId, request.tenantId()).in(IamMembership::getId, request.recipientIds())); + case USER -> memberships.selectList(new LambdaQueryWrapper() + .eq(IamMembership::getTenantId, request.tenantId()).in(IamMembership::getUserId, request.recipientIds())); + case TENANT_ALL -> memberships.selectList(new LambdaQueryWrapper() + .eq(IamMembership::getTenantId, request.tenantId())); + case TENANT_ROLE, PLATFORM_ROLE -> byRole(request); + }; + Map 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 byRole(CreateNotificationRequest request) { + var role = roles.selectOne(new LambdaQueryWrapper() + .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 ids = membershipRoles.selectList(new LambdaQueryWrapper() + .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){} +} diff --git a/iam/src/main/java/com/baiyue/iam/notification/service/NotificationScheduler.java b/iam/src/main/java/com/baiyue/iam/notification/service/NotificationScheduler.java new file mode 100644 index 000000000..768283ccf --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/service/NotificationScheduler.java @@ -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();} +} diff --git a/iam/src/main/java/com/baiyue/iam/notification/service/NotificationService.java b/iam/src/main/java/com/baiyue/iam/notification/service/NotificationService.java new file mode 100644 index 000000000..5ea740fd6 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/service/NotificationService.java @@ -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 inbox(long tenantId, long membershipId, String appCode, + long page, long size, String status, String category) { + PageResult 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 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 whitelist = readWhitelist(template.getVariableWhitelistJson()); + String title = renderer.render(template.getTitleTemplate(), whitelist, request.variables()); + String body = renderer.render(template.getBodyTemplate(), whitelist, request.variables()); + List 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 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 templates(long page, long size, String code, String category, + String title, String status) { + PageResult 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 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 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 tasks(long page, long size, String taskNo, String category, + String title, String status) { + PageResult 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 readWhitelist(String value) { + try { return objectMapper.readValue(value, new TypeReference>() { }); } + 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); } +} diff --git a/iam/src/main/java/com/baiyue/iam/notification/service/NotificationTemplateRenderer.java b/iam/src/main/java/com/baiyue/iam/notification/service/NotificationTemplateRenderer.java new file mode 100644 index 000000000..f05b8f7ea --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/service/NotificationTemplateRenderer.java @@ -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 whitelist, Map variables) { + Set 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 whitelist) { + if (whitelist.size() > 32) fail("NOTIFICATION_VARIABLE_NOT_ALLOWED"); + Set used = new HashSet<>(placeholders(title)); used.addAll(placeholders(body)); + if (!whitelist.containsAll(used)) fail("NOTIFICATION_VARIABLE_NOT_ALLOWED"); + } + + private Set placeholders(String value) { + Set 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); } +} diff --git a/iam/src/main/resources/application.yml b/iam/src/main/resources/application.yml index a77bec0d0..e79f73311 100644 --- a/iam/src/main/resources/application.yml +++ b/iam/src/main/resources/application.yml @@ -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:} diff --git a/iam/src/main/resources/db/notification-center.sql b/iam/src/main/resources/db/notification-center.sql new file mode 100644 index 000000000..671771fe4 --- /dev/null +++ b/iam/src/main/resources/db/notification-center.sql @@ -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'; diff --git a/iam/src/main/resources/openapi/notification-v1.yaml b/iam/src/main/resources/openapi/notification-v1.yaml index 66054010a..a232c5190 100644 --- a/iam/src/main/resources/openapi/notification-v1.yaml +++ b/iam/src/main/resources/openapi/notification-v1.yaml @@ -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'}}} diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java new file mode 100644 index 000000000..afab24b08 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java @@ -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 api = new Yaml().load(document); + assertThat(api).containsEntry("openapi", "3.0.3"); + Map 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 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 components = map(api.get("components")); + Map 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 paths, String path, String method) { + assertThat(map(map(paths.get(path)).get(method))).containsKey("requestBody"); + } + + @SuppressWarnings("unchecked") + private Map map(Object value) { return (Map) value; } + @SuppressWarnings("unchecked") + private List list(Object value) { return (List) value; } +} diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationServiceTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationServiceTest.java new file mode 100644 index 000000000..ad6266234 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationServiceTest.java @@ -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 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 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; + } +} diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationSqlContractTest.java new file mode 100644 index 000000000..c7f6add54 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationSqlContractTest.java @@ -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");} +} diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationTemplateRendererTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationTemplateRendererTest.java new file mode 100644 index 000000000..38ff24dcd --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationTemplateRendererTest.java @@ -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");} +}