feat(站内通知): 铃铛面板支持时间/内容搜索、按天分组与分页
- 列表接口加 keyword(标题/内容模糊)与 startDate/endDate(年月日闭区间)参数, 两端控制器透传,服务端补筛选日志 - 两端铃铛面板:搜索框(防抖 300ms)+ 日期区间 + 按年月日分组 + 翻页, 面板改 Teleport 到 body(挂在顶栏时会被页面 el-select 压住,提 z-index 无效) - 固化可见范围回归测试:超管全量/管理员只看本组/普通用户只看自己, 含读写两侧的 user_id+audience 裁剪断言与两端控制器身份来源断言
This commit is contained in:
+14
-4
@@ -2,6 +2,7 @@ package com.nanri.aiimage.modules.notification.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
||||
import com.nanri.aiimage.modules.notification.model.dto.NotificationPageQuery;
|
||||
import com.nanri.aiimage.modules.notification.model.vo.NotificationPageVo;
|
||||
import com.nanri.aiimage.modules.notification.model.vo.NotificationSummaryVo;
|
||||
import com.nanri.aiimage.modules.notification.service.NotificationService;
|
||||
@@ -11,6 +12,7 @@ import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -18,6 +20,8 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 后台站内通知(铃铛):超管与管理员共用,按登录管理员维度读写 audience=admin 的通知;
|
||||
* 可见范围(全量 or 分组内成员)在通知生成时已按数据权限过滤。
|
||||
@@ -39,15 +43,21 @@ public class AdminNotificationController {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "通知分页列表")
|
||||
@Operation(summary = "通知分页列表",
|
||||
description = "onlyUnread=true 时只返回未读;keyword 模糊匹配标题/内容;startDate/endDate 为年月日闭区间。")
|
||||
public ApiResponse<NotificationPageVo> page(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page,
|
||||
@Parameter(description = "每页数量") @RequestParam(defaultValue = "20") Long pageSize,
|
||||
@Parameter(description = "只看未读") @RequestParam(required = false, defaultValue = "false") Boolean onlyUnread) {
|
||||
@Parameter(description = "只看未读") @RequestParam(required = false, defaultValue = "false") Boolean onlyUnread,
|
||||
@Parameter(description = "关键字(标题/内容)") @RequestParam(required = false) String keyword,
|
||||
@Parameter(description = "起始日期(yyyy-MM-dd,含)")
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||
@Parameter(description = "结束日期(yyyy-MM-dd,含)")
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
|
||||
Long userId = currentAdminId(request);
|
||||
return ApiResponse.success(notificationService.page(
|
||||
userId, NotificationService.AUDIENCE_ADMIN, page, pageSize, Boolean.TRUE.equals(onlyUnread)));
|
||||
return ApiResponse.success(notificationService.page(userId, NotificationService.AUDIENCE_ADMIN,
|
||||
NotificationPageQuery.of(page, pageSize, onlyUnread, keyword, startDate, endDate)));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/read")
|
||||
|
||||
+14
-4
@@ -2,6 +2,7 @@ package com.nanri.aiimage.modules.notification.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
||||
import com.nanri.aiimage.modules.notification.model.dto.NotificationPageQuery;
|
||||
import com.nanri.aiimage.modules.notification.model.vo.NotificationPageVo;
|
||||
import com.nanri.aiimage.modules.notification.model.vo.NotificationSummaryVo;
|
||||
import com.nanri.aiimage.modules.notification.service.NotificationService;
|
||||
@@ -11,6 +12,7 @@ import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -18,6 +20,8 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 桌面端站内通知(铃铛):当前登录用户维度,用户身份一律从 JWT 解析;
|
||||
* 只读写 audience=user 的通知,不感知后台管理员通知。
|
||||
@@ -39,15 +43,21 @@ public class NotificationController {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "通知分页列表", description = "onlyUnread=true 时只返回未读;附带未读总数。")
|
||||
@Operation(summary = "通知分页列表",
|
||||
description = "onlyUnread=true 时只返回未读;keyword 模糊匹配标题/内容;startDate/endDate 为年月日闭区间。")
|
||||
public ApiResponse<NotificationPageVo> page(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page,
|
||||
@Parameter(description = "每页数量") @RequestParam(defaultValue = "20") Long pageSize,
|
||||
@Parameter(description = "只看未读") @RequestParam(required = false, defaultValue = "false") Boolean onlyUnread) {
|
||||
@Parameter(description = "只看未读") @RequestParam(required = false, defaultValue = "false") Boolean onlyUnread,
|
||||
@Parameter(description = "关键字(标题/内容)") @RequestParam(required = false) String keyword,
|
||||
@Parameter(description = "起始日期(yyyy-MM-dd,含)")
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||
@Parameter(description = "结束日期(yyyy-MM-dd,含)")
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
|
||||
Long userId = currentUserId(request);
|
||||
return ApiResponse.success(notificationService.page(
|
||||
userId, NotificationService.AUDIENCE_USER, page, pageSize, Boolean.TRUE.equals(onlyUnread)));
|
||||
return ApiResponse.success(notificationService.page(userId, NotificationService.AUDIENCE_USER,
|
||||
NotificationPageQuery.of(page, pageSize, onlyUnread, keyword, startDate, endDate)));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/read")
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.nanri.aiimage.modules.notification.model.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 通知列表查询条件:分页 + 未读过滤 + 关键字(标题/内容模糊)+ 创建日期区间(年月日闭区间)。
|
||||
* 铃铛面板的「按天搜索」与「内容搜索」都走这里,空值表示不限制。
|
||||
*/
|
||||
@Data
|
||||
public class NotificationPageQuery {
|
||||
|
||||
private Long page;
|
||||
private Long pageSize;
|
||||
private Boolean onlyUnread;
|
||||
private String keyword;
|
||||
private LocalDate startDate;
|
||||
private LocalDate endDate;
|
||||
|
||||
public static NotificationPageQuery of(Long page, Long pageSize, Boolean onlyUnread,
|
||||
String keyword, LocalDate startDate, LocalDate endDate) {
|
||||
NotificationPageQuery query = new NotificationPageQuery();
|
||||
query.setPage(page);
|
||||
query.setPageSize(pageSize);
|
||||
query.setOnlyUnread(onlyUnread);
|
||||
query.setKeyword(keyword);
|
||||
query.setStartDate(startDate);
|
||||
query.setEndDate(endDate);
|
||||
return query;
|
||||
}
|
||||
}
|
||||
+34
-6
@@ -3,6 +3,7 @@ package com.nanri.aiimage.modules.notification.service;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.nanri.aiimage.modules.notification.mapper.UserNotificationMapper;
|
||||
import com.nanri.aiimage.modules.notification.model.dto.NotificationPageQuery;
|
||||
import com.nanri.aiimage.modules.notification.model.entity.UserNotificationEntity;
|
||||
import com.nanri.aiimage.modules.notification.model.vo.NotificationItemVo;
|
||||
import com.nanri.aiimage.modules.notification.model.vo.NotificationPageVo;
|
||||
@@ -113,16 +114,20 @@ public class NotificationService {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 分页查询(id 倒序);onlyUnread=true 时只返回未读。 */ public NotificationPageVo page(Long userId, String audience, long page, long pageSize, boolean onlyUnread) {
|
||||
long safePage = page < 1 ? 1L : page;
|
||||
long safeSize = pageSize < 1 ? 20L : Math.min(pageSize, MAX_PAGE_SIZE);
|
||||
LambdaQueryWrapper<UserNotificationEntity> countWrapper = baseWrapper(userId, audience, onlyUnread);
|
||||
/** 分页查询(id 倒序);onlyUnread=true 时只返回未读,keyword/日期区间为空表示不限制。 */
|
||||
public NotificationPageVo page(Long userId, String audience, NotificationPageQuery query) {
|
||||
NotificationPageQuery safe = query == null ? new NotificationPageQuery() : query;
|
||||
long safePage = safe.getPage() == null || safe.getPage() < 1 ? 1L : safe.getPage();
|
||||
long safeSize = safe.getPageSize() == null || safe.getPageSize() < 1
|
||||
? 20L : Math.min(safe.getPageSize(), MAX_PAGE_SIZE);
|
||||
boolean onlyUnread = Boolean.TRUE.equals(safe.getOnlyUnread());
|
||||
LambdaQueryWrapper<UserNotificationEntity> countWrapper = baseWrapper(userId, audience, onlyUnread, safe);
|
||||
Long totalValue = userNotificationMapper.selectCount(countWrapper);
|
||||
long total = totalValue == null ? 0L : totalValue;
|
||||
|
||||
long offset = Math.max(0L, (safePage - 1) * safeSize);
|
||||
List<UserNotificationEntity> rows = total == 0 ? List.of()
|
||||
: userNotificationMapper.selectList(baseWrapper(userId, audience, onlyUnread)
|
||||
: userNotificationMapper.selectList(baseWrapper(userId, audience, onlyUnread, safe)
|
||||
.orderByDesc(UserNotificationEntity::getId)
|
||||
.last("limit " + offset + "," + safeSize));
|
||||
|
||||
@@ -136,6 +141,9 @@ public class NotificationService {
|
||||
vo.setPage(safePage);
|
||||
vo.setPageSize(safeSize);
|
||||
vo.setUnreadCount(unreadCount(userId, audience));
|
||||
log.info("[notification] 列表查询 userId={} audience={} page={} size={} onlyUnread={} keyword={} 起={} 止={} 命中={}",
|
||||
userId, audience, safePage, safeSize, onlyUnread, normalize(safe.getKeyword()),
|
||||
safe.getStartDate(), safe.getEndDate(), total);
|
||||
return vo;
|
||||
}
|
||||
|
||||
@@ -207,16 +215,36 @@ public class NotificationService {
|
||||
return deleted;
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<UserNotificationEntity> baseWrapper(Long userId, String audience, boolean onlyUnread) {
|
||||
private LambdaQueryWrapper<UserNotificationEntity> baseWrapper(Long userId, String audience, boolean onlyUnread,
|
||||
NotificationPageQuery query) {
|
||||
LambdaQueryWrapper<UserNotificationEntity> wrapper = new LambdaQueryWrapper<UserNotificationEntity>()
|
||||
.eq(UserNotificationEntity::getUserId, userId)
|
||||
.eq(UserNotificationEntity::getAudience, audience);
|
||||
if (onlyUnread) {
|
||||
wrapper.isNull(UserNotificationEntity::getReadAt);
|
||||
}
|
||||
applyFilters(wrapper, query);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表筛选:关键字模糊匹配标题/内容;日期按「年月日」闭区间
|
||||
* (起日 00:00 起含、止日次日 00:00 前不含,避免当天 23:59 漏行)。
|
||||
*/
|
||||
private void applyFilters(LambdaQueryWrapper<UserNotificationEntity> wrapper, NotificationPageQuery query) {
|
||||
String keyword = normalize(query.getKeyword());
|
||||
if (!keyword.isEmpty()) {
|
||||
wrapper.and(nested -> nested.like(UserNotificationEntity::getTitle, keyword)
|
||||
.or().like(UserNotificationEntity::getContent, keyword));
|
||||
}
|
||||
if (query.getStartDate() != null) {
|
||||
wrapper.ge(UserNotificationEntity::getCreatedAt, query.getStartDate().atStartOfDay());
|
||||
}
|
||||
if (query.getEndDate() != null) {
|
||||
wrapper.lt(UserNotificationEntity::getCreatedAt, query.getEndDate().plusDays(1).atStartOfDay());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean existsByDedupeKey(String dedupeKey) {
|
||||
return selectByDedupeKey(dedupeKey) != null;
|
||||
}
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package com.nanri.aiimage.modules.notification.controller;
|
||||
|
||||
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
||||
import com.nanri.aiimage.modules.notification.model.dto.NotificationPageQuery;
|
||||
import com.nanri.aiimage.modules.notification.service.NotificationService;
|
||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 后台通知可见范围回归:登录管理员只能按自己的 uid 读写 audience=admin 的通知副本。
|
||||
* 「超管看全部、管理员只看本组」在生成时按数据权限裁剪(见 NotificationDispatchService),
|
||||
* 这里守住另一半:任何管理员都不可能通过接口读到别人的副本或桌面端(audience=user)的通知。
|
||||
*/
|
||||
class AdminNotificationControllerTest {
|
||||
|
||||
private final NotificationService notificationService = mock(NotificationService.class);
|
||||
private final AdminAuthSupport adminAuthSupport = mock(AdminAuthSupport.class);
|
||||
private final HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
private final AdminNotificationController controller =
|
||||
new AdminNotificationController(notificationService, adminAuthSupport);
|
||||
|
||||
@Test
|
||||
void listAndSummaryUseOperatorIdAndAdminAudience() {
|
||||
when(adminAuthSupport.requireAdmin(request)).thenReturn(admin(24L));
|
||||
|
||||
controller.summary(request);
|
||||
controller.page(request, 1L, 20L, false, "余额", LocalDate.of(2026, 9, 10), LocalDate.of(2026, 9, 13));
|
||||
|
||||
verify(notificationService).summary(24L, NotificationService.AUDIENCE_ADMIN);
|
||||
ArgumentCaptor<NotificationPageQuery> queryCaptor = ArgumentCaptor.forClass(NotificationPageQuery.class);
|
||||
verify(notificationService).page(eq(24L), eq(NotificationService.AUDIENCE_ADMIN), queryCaptor.capture());
|
||||
NotificationPageQuery query = queryCaptor.getValue();
|
||||
assertThat(query.getPage()).isEqualTo(1L);
|
||||
assertThat(query.getPageSize()).isEqualTo(20L);
|
||||
assertThat(query.getOnlyUnread()).isFalse();
|
||||
assertThat(query.getKeyword()).isEqualTo("余额");
|
||||
assertThat(query.getStartDate()).isEqualTo(LocalDate.of(2026, 9, 10));
|
||||
assertThat(query.getEndDate()).isEqualTo(LocalDate.of(2026, 9, 13));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readMarkingStaysWithinOwnAdminAudienceRows() {
|
||||
when(adminAuthSupport.requireAdmin(request)).thenReturn(admin(1L));
|
||||
|
||||
controller.markRead(request, 18L);
|
||||
controller.markAllRead(request);
|
||||
|
||||
verify(notificationService).markRead(1L, NotificationService.AUDIENCE_ADMIN, 18L);
|
||||
verify(notificationService).markAllRead(1L, NotificationService.AUDIENCE_ADMIN);
|
||||
}
|
||||
|
||||
private AdminUserEntity admin(Long id) {
|
||||
AdminUserEntity entity = new AdminUserEntity();
|
||||
entity.setId(id);
|
||||
entity.setUsername("管理员" + id);
|
||||
entity.setIsAdmin(1);
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package com.nanri.aiimage.modules.notification.controller;
|
||||
|
||||
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
||||
import com.nanri.aiimage.modules.notification.model.dto.NotificationPageQuery;
|
||||
import com.nanri.aiimage.modules.notification.service.NotificationService;
|
||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 桌面端通知可见范围回归:接收者只能来自 JWT 解析出的本人,
|
||||
* 且一律按 audience=user 读写 —— 普通用户只能看到自己的通知,读不到后台管理员通知。
|
||||
*/
|
||||
class NotificationControllerTest {
|
||||
|
||||
private final NotificationService notificationService = mock(NotificationService.class);
|
||||
private final AdminAuthSupport adminAuthSupport = mock(AdminAuthSupport.class);
|
||||
private final HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
private final NotificationController controller =
|
||||
new NotificationController(notificationService, adminAuthSupport);
|
||||
|
||||
@Test
|
||||
void listAndSummaryUseJwtUserAndUserAudience() {
|
||||
when(adminAuthSupport.requireUser(request)).thenReturn(user(1095L));
|
||||
|
||||
controller.summary(request);
|
||||
controller.page(request, 2L, 20L, true, "欠费", LocalDate.of(2026, 9, 1), LocalDate.of(2026, 9, 13));
|
||||
|
||||
verify(notificationService).summary(1095L, NotificationService.AUDIENCE_USER);
|
||||
ArgumentCaptor<NotificationPageQuery> queryCaptor = ArgumentCaptor.forClass(NotificationPageQuery.class);
|
||||
verify(notificationService).page(eq(1095L), eq(NotificationService.AUDIENCE_USER), queryCaptor.capture());
|
||||
NotificationPageQuery query = queryCaptor.getValue();
|
||||
assertThat(query.getPage()).isEqualTo(2L);
|
||||
assertThat(query.getPageSize()).isEqualTo(20L);
|
||||
assertThat(query.getOnlyUnread()).isTrue();
|
||||
assertThat(query.getKeyword()).isEqualTo("欠费");
|
||||
assertThat(query.getStartDate()).isEqualTo(LocalDate.of(2026, 9, 1));
|
||||
assertThat(query.getEndDate()).isEqualTo(LocalDate.of(2026, 9, 13));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readMarkingStaysWithinOwnUserAudienceRows() {
|
||||
when(adminAuthSupport.requireUser(request)).thenReturn(user(1095L));
|
||||
|
||||
controller.markRead(request, 42L);
|
||||
controller.markAllRead(request);
|
||||
|
||||
verify(notificationService).markRead(1095L, NotificationService.AUDIENCE_USER, 42L);
|
||||
verify(notificationService).markAllRead(1095L, NotificationService.AUDIENCE_USER);
|
||||
}
|
||||
|
||||
private AdminUserEntity user(Long id) {
|
||||
AdminUserEntity entity = new AdminUserEntity();
|
||||
entity.setId(id);
|
||||
entity.setUsername("用户" + id);
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
+54
@@ -14,6 +14,8 @@ import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class NotificationDispatchServiceTest {
|
||||
@@ -71,6 +73,58 @@ class NotificationDispatchServiceTest {
|
||||
eq("标题"), eq("内容"), eq("task_failed_admin:20:PRICE_TRACK:2026091310:1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void pushToAdminsDeliversSuperAndInScopeAdminsWithOwnDedupeKey() {
|
||||
AdminUserEntity superAdmin = admin(1L, "超管甲");
|
||||
AdminUserEntity inScope = admin(2L, "主管乙");
|
||||
AdminUserEntity outOfScope = admin(3L, "主管丙");
|
||||
when(adminUserMapper.selectList(any())).thenReturn(List.of(superAdmin, inScope, outOfScope));
|
||||
when(adminAuthSupport.currentRole(superAdmin)).thenReturn("super_admin");
|
||||
when(adminAuthSupport.currentRole(inScope)).thenReturn("admin");
|
||||
when(adminAuthSupport.currentRole(outOfScope)).thenReturn("admin");
|
||||
when(userDataScopeSupport.resolveVisibleUserIds(2L)).thenReturn(List.of(2L, 20L));
|
||||
when(userDataScopeSupport.resolveVisibleUserIds(3L)).thenReturn(List.of(3L, 30L));
|
||||
when(notificationService.pushOrRefresh(anyLong(), anyString(), anyString(), anyString(), anyString(), anyString(), anyString()))
|
||||
.thenReturn(true);
|
||||
|
||||
int pushed = service.pushToAdmins(NotificationService.SCENE_TASK_FAILED,
|
||||
NotificationService.LEVEL_WARNING, "标题", "内容",
|
||||
"task_failed_admin:20:PRICE_TRACK:2026091310", 20L);
|
||||
|
||||
// 超管全量 + 本组主管各落一条,各带自己的 dedupe 后缀(已读状态互不影响)
|
||||
assertThat(pushed).isEqualTo(2);
|
||||
verify(notificationService).pushOrRefresh(eq(1L), eq(NotificationService.AUDIENCE_ADMIN),
|
||||
eq(NotificationService.SCENE_TASK_FAILED), eq(NotificationService.LEVEL_WARNING),
|
||||
eq("标题"), eq("内容"), eq("task_failed_admin:20:PRICE_TRACK:2026091310:1"));
|
||||
verify(notificationService).pushOrRefresh(eq(2L), eq(NotificationService.AUDIENCE_ADMIN),
|
||||
eq(NotificationService.SCENE_TASK_FAILED), eq(NotificationService.LEVEL_WARNING),
|
||||
eq("标题"), eq("内容"), eq("task_failed_admin:20:PRICE_TRACK:2026091310:2"));
|
||||
// 非本组主管一条都不落
|
||||
verify(notificationService, never()).pushOrRefresh(eq(3L), anyString(), anyString(),
|
||||
anyString(), anyString(), anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void globalEventReachesEveryAdminByDesign() {
|
||||
AdminUserEntity superAdmin = admin(1L, "超管甲");
|
||||
AdminUserEntity adminA = admin(2L, "主管乙");
|
||||
AdminUserEntity adminB = admin(3L, "主管丙");
|
||||
when(adminUserMapper.selectList(any())).thenReturn(List.of(superAdmin, adminA, adminB));
|
||||
when(adminAuthSupport.currentRole(superAdmin)).thenReturn("super_admin");
|
||||
when(adminAuthSupport.currentRole(adminA)).thenReturn("admin");
|
||||
when(adminAuthSupport.currentRole(adminB)).thenReturn("admin");
|
||||
when(userDataScopeSupport.resolveVisibleUserIds(anyLong())).thenReturn(List.of());
|
||||
when(notificationService.pushOrRefresh(anyLong(), anyString(), anyString(), anyString(), anyString(), anyString(), anyString()))
|
||||
.thenReturn(true);
|
||||
|
||||
// 服务探测失败这类全局事件(subjectUserId=null)按设计推给所有管理员,不受分组限制
|
||||
int pushed = service.pushToAdmins(NotificationService.SCENE_SERVICE_DOWN,
|
||||
NotificationService.LEVEL_ERROR, "品牌检测服务不可用", "探测失败",
|
||||
"service_down:brand-service:2026091310", null);
|
||||
|
||||
assertThat(pushed).isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pushToUserUsesUserAudience() {
|
||||
when(notificationService.pushOrRefresh(anyLong(), anyString(), anyString(), anyString(), anyString(), anyString(), anyString()))
|
||||
|
||||
+95
-2
@@ -1,8 +1,11 @@
|
||||
package com.nanri.aiimage.modules.notification.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.nanri.aiimage.modules.notification.mapper.UserNotificationMapper;
|
||||
import com.nanri.aiimage.modules.notification.model.dto.NotificationPageQuery;
|
||||
import com.nanri.aiimage.modules.notification.model.entity.UserNotificationEntity;
|
||||
import com.nanri.aiimage.modules.notification.model.vo.NotificationPageVo;
|
||||
import com.nanri.aiimage.modules.notification.model.vo.NotificationSummaryVo;
|
||||
@@ -11,6 +14,7 @@ import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@@ -128,7 +132,8 @@ class NotificationServiceTest {
|
||||
when(mapper.selectCount(any())).thenReturn(2L, 1L);
|
||||
when(mapper.selectList(any())).thenReturn(List.of(first, second));
|
||||
|
||||
NotificationPageVo page = service.page(7L, NotificationService.AUDIENCE_USER, 1, 20, false);
|
||||
NotificationPageVo page = service.page(7L, NotificationService.AUDIENCE_USER,
|
||||
NotificationPageQuery.of(1L, 20L, false, null, null, null));
|
||||
|
||||
assertThat(page.getItems()).hasSize(2);
|
||||
assertThat(page.getItems().get(0).getRead()).isTrue();
|
||||
@@ -143,7 +148,8 @@ class NotificationServiceTest {
|
||||
void pageClampsPageSizeAndSkipsQueryWhenEmpty() {
|
||||
when(mapper.selectCount(any())).thenReturn(0L, 0L);
|
||||
|
||||
NotificationPageVo page = service.page(7L, NotificationService.AUDIENCE_ADMIN, 0, 500, true);
|
||||
NotificationPageVo page = service.page(7L, NotificationService.AUDIENCE_ADMIN,
|
||||
NotificationPageQuery.of(0L, 500L, true, null, null, null));
|
||||
|
||||
assertThat(page.getItems()).isEmpty();
|
||||
assertThat(page.getPage()).isEqualTo(1L);
|
||||
@@ -186,4 +192,91 @@ class NotificationServiceTest {
|
||||
assertThat(deleted).isEqualTo(2);
|
||||
verify(mapper).delete(any());
|
||||
}
|
||||
|
||||
/**
|
||||
* 可见范围回归:读取侧必须按「接收者 user_id + 端 audience」双条件裁剪。
|
||||
* 一旦有人漏掉 user_id,管理员/用户就会看到别人的通知;漏掉 audience 则两端通知串台。
|
||||
*/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void pageScopesQueryByReceiverAndAudience() {
|
||||
when(mapper.selectCount(any())).thenReturn(2L, 1L);
|
||||
when(mapper.selectList(any())).thenReturn(List.of());
|
||||
|
||||
service.page(24L, NotificationService.AUDIENCE_ADMIN,
|
||||
NotificationPageQuery.of(1L, 20L, true, null, null, null));
|
||||
|
||||
ArgumentCaptor<LambdaQueryWrapper<UserNotificationEntity>> countCaptor =
|
||||
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||
verify(mapper, times(2)).selectCount(countCaptor.capture());
|
||||
countCaptor.getAllValues().forEach(wrapper -> assertScopedTo(wrapper, 24L, "admin"));
|
||||
ArgumentCaptor<LambdaQueryWrapper<UserNotificationEntity>> listCaptor =
|
||||
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||
verify(mapper).selectList(listCaptor.capture());
|
||||
assertScopedTo(listCaptor.getValue(), 24L, "admin");
|
||||
}
|
||||
|
||||
/** 关键字匹配标题/内容;日期按「年月日」闭区间(止日次日 0 点为上界,当天 23:59 不漏)。 */
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void pageAppliesKeywordAndDayRangeFilters() {
|
||||
when(mapper.selectCount(any())).thenReturn(1L, 0L);
|
||||
when(mapper.selectList(any())).thenReturn(List.of());
|
||||
|
||||
service.page(24L, NotificationService.AUDIENCE_ADMIN, NotificationPageQuery.of(
|
||||
1L, 20L, false, "余额不足", LocalDate.of(2026, 9, 10), LocalDate.of(2026, 9, 13)));
|
||||
|
||||
ArgumentCaptor<LambdaQueryWrapper<UserNotificationEntity>> listCaptor =
|
||||
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||
verify(mapper).selectList(listCaptor.capture());
|
||||
LambdaQueryWrapper<UserNotificationEntity> wrapper = listCaptor.getValue();
|
||||
assertThat(wrapper.getSqlSegment()).contains("title").contains("content").contains("created_at");
|
||||
assertThat(wrapper.getParamNameValuePairs().values())
|
||||
.contains("%余额不足%", LocalDateTime.of(2026, 9, 10, 0, 0), LocalDateTime.of(2026, 9, 14, 0, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void summaryAndUnreadCountScopeByReceiverAndAudience() {
|
||||
when(mapper.selectCount(any())).thenReturn(3L);
|
||||
when(mapper.selectOne(any())).thenReturn(null);
|
||||
|
||||
service.summary(1095L, NotificationService.AUDIENCE_USER);
|
||||
service.unreadCount(1095L, NotificationService.AUDIENCE_USER);
|
||||
|
||||
ArgumentCaptor<LambdaQueryWrapper<UserNotificationEntity>> countCaptor =
|
||||
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||
verify(mapper, times(2)).selectCount(countCaptor.capture());
|
||||
countCaptor.getAllValues().forEach(wrapper -> assertScopedTo(wrapper, 1095L, "user"));
|
||||
ArgumentCaptor<LambdaQueryWrapper<UserNotificationEntity>> oneCaptor =
|
||||
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||
verify(mapper).selectOne(oneCaptor.capture());
|
||||
assertScopedTo(oneCaptor.getValue(), 1095L, "user");
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void markReadCannotTouchOtherReceiversOrOtherAudience() {
|
||||
when(mapper.update(any(), any())).thenReturn(1);
|
||||
|
||||
service.markRead(24L, NotificationService.AUDIENCE_ADMIN, 42L);
|
||||
service.markAllRead(24L, NotificationService.AUDIENCE_ADMIN);
|
||||
|
||||
ArgumentCaptor<LambdaUpdateWrapper<UserNotificationEntity>> captor =
|
||||
ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
|
||||
verify(mapper, times(2)).update(any(), captor.capture());
|
||||
captor.getAllValues().forEach(wrapper -> assertScopedTo(wrapper, 24L, "admin"));
|
||||
// 单条已读还必须锁定通知 id,避免参数错位时误标他人的行
|
||||
assertThat(captor.getAllValues().get(0).getSqlSegment()).contains("id =");
|
||||
}
|
||||
|
||||
private void assertScopedTo(LambdaQueryWrapper<UserNotificationEntity> wrapper, long userId, String audience) {
|
||||
assertThat(wrapper.getSqlSegment()).contains("user_id").contains("audience");
|
||||
assertThat(wrapper.getParamNameValuePairs().values()).contains(userId, audience);
|
||||
}
|
||||
|
||||
private void assertScopedTo(LambdaUpdateWrapper<UserNotificationEntity> wrapper, long userId, String audience) {
|
||||
assertThat(wrapper.getSqlSegment()).contains("user_id").contains("audience");
|
||||
assertThat(wrapper.getParamNameValuePairs().values()).contains(userId, audience);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user