feat(站内通知): 铃铛支持按类型大类筛选(系统异常/任务异常/配置异常/系统通知)

原来铃铛只能按关键字和日期筛,任务失败、密钥欠费、麦象异常全混在一起,
用户没法只看自己关心的那类。

后端 NotificationService 增加 scene→大类映射(未知 scene 归「系统通知」兜底),
列表接口新增 category 参数,落到 SQL 是 scene IN (...);「系统通知」作兜底类
还要纳入未登记的 scene,故表达为「= system 或 不在其它三类里」。列表项返回
category,前端不必各自维护一份 scene 映射。前后端两个通知接口同步加参数。

前端铃铛筛选区新增一行类型 chip(全部 + 四类),与关键字、日期一起参与
hasFilter 与重置;空分类不下发,避免后端查询落空。
This commit is contained in:
2026-09-16 10:32:11 +08:00
parent f566573fce
commit aea0e16279
11 changed files with 318 additions and 29 deletions
@@ -44,20 +44,24 @@ public class AdminNotificationController {
@GetMapping
@Operation(summary = "通知分页列表",
description = "onlyUnread=true 时只返回未读;keyword 模糊匹配标题/内容;startDate/endDate 为年月日闭区间。")
description = "onlyUnread=true 时只返回未读;keyword 模糊匹配标题/内容;"
+ "category 按类型大类筛选(system_error/task_error/config_error/system_notice);"
+ "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) String keyword,
@Parameter(description = "类型大类:system_error/task_error/config_error/system_notice")
@RequestParam(required = false) String category,
@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,
NotificationPageQuery.of(page, pageSize, onlyUnread, keyword, startDate, endDate)));
NotificationPageQuery.of(page, pageSize, onlyUnread, keyword, category, startDate, endDate)));
}
@PostMapping("/{id}/read")
@@ -44,20 +44,24 @@ public class NotificationController {
@GetMapping
@Operation(summary = "通知分页列表",
description = "onlyUnread=true 时只返回未读;keyword 模糊匹配标题/内容;startDate/endDate 为年月日闭区间。")
description = "onlyUnread=true 时只返回未读;keyword 模糊匹配标题/内容;"
+ "category 按类型大类筛选(system_error/task_error/config_error/system_notice);"
+ "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) String keyword,
@Parameter(description = "类型大类:system_error/task_error/config_error/system_notice")
@RequestParam(required = false) String category,
@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,
NotificationPageQuery.of(page, pageSize, onlyUnread, keyword, startDate, endDate)));
NotificationPageQuery.of(page, pageSize, onlyUnread, keyword, category, startDate, endDate)));
}
@PostMapping("/{id}/read")
@@ -5,8 +5,9 @@ import lombok.Data;
import java.time.LocalDate;
/**
* 通知列表查询条件:分页 + 未读过滤 + 关键字(标题/内容模糊)+ 创建日期区间(年月日闭区间)
* 铃铛面板的「按天搜索」与「内容搜索」都走这里,空值表示不限制
* 通知列表查询条件:分页 + 未读过滤 + 关键字(标题/内容模糊)+ 创建日期区间(年月日闭区间)
* + 类型大类(system_error/task_error/config_error/system_notice
* 铃铛面板的「按天搜索」「内容搜索」「类型筛选」都走这里,空值表示不限制。
*/
@Data
public class NotificationPageQuery {
@@ -15,16 +16,19 @@ public class NotificationPageQuery {
private Long pageSize;
private Boolean onlyUnread;
private String keyword;
private String category;
private LocalDate startDate;
private LocalDate endDate;
public static NotificationPageQuery of(Long page, Long pageSize, Boolean onlyUnread,
String keyword, LocalDate startDate, LocalDate endDate) {
String keyword, String category,
LocalDate startDate, LocalDate endDate) {
NotificationPageQuery query = new NotificationPageQuery();
query.setPage(page);
query.setPageSize(pageSize);
query.setOnlyUnread(onlyUnread);
query.setKeyword(keyword);
query.setCategory(category);
query.setStartDate(startDate);
query.setEndDate(endDate);
return query;
@@ -11,6 +11,8 @@ public class NotificationItemVo {
private Long id;
/** 场景:secret_balance/secret_invalid/task_failed/service_down/system */
private String scene;
/** 类型大类(铃铛筛选用):system_error/task_error/config_error/system_notice */
private String category;
/** 级别:info/warning/error */
private String level;
private String title;
@@ -15,6 +15,7 @@ import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 站内通知存取:桌面端用户(audience=user)与后台管理员(audience=admin)共用一张表,
@@ -40,6 +41,27 @@ public class NotificationService {
public static final String SCENE_MAIXIANG_ANOMALY = "maixiang_anomaly";
public static final String SCENE_SYSTEM = "system";
/**
* 通知大类:scene 太细(技术语义),铃铛按用户看得懂的四类归并筛选。
* null/空分类表示不筛选,见 {@link #scenesOfCategory}。
*/
public static final String CATEGORY_SYSTEM_ERROR = "system_error";
public static final String CATEGORY_TASK_ERROR = "task_error";
public static final String CATEGORY_CONFIG_ERROR = "config_error";
public static final String CATEGORY_SYSTEM_NOTICE = "system_notice";
/**
* scene → 大类。未知 scene(历史遗留或新增未登记)归入「系统通知」,
* 保证任一通知只属于一类、筛选项互斥且完备。
*/
private static final Map<String, String> SCENE_TO_CATEGORY = Map.of(
SCENE_SERVICE_DOWN, CATEGORY_SYSTEM_ERROR,
SCENE_MAIXIANG_ANOMALY, CATEGORY_SYSTEM_ERROR,
SCENE_TASK_FAILED, CATEGORY_TASK_ERROR,
SCENE_SECRET_BALANCE, CATEGORY_CONFIG_ERROR,
SCENE_SECRET_INVALID, CATEGORY_CONFIG_ERROR,
SCENE_SYSTEM, CATEGORY_SYSTEM_NOTICE);
private static final long MAX_PAGE_SIZE = 100L;
private static final int TITLE_MAX_LENGTH = 128;
private static final int CONTENT_MAX_LENGTH = 512;
@@ -143,12 +165,35 @@ 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);
log.info("[notification] 列表查询 userId={} audience={} page={} size={} onlyUnread={} category={} keyword={} 起={} 止={} 命中={}",
userId, audience, safePage, safeSize, onlyUnread, normalize(safe.getCategory()),
normalize(safe.getKeyword()), safe.getStartDate(), safe.getEndDate(), total);
return vo;
}
/** scene → 用户可见大类;未知 scene(历史遗留/新增未登记)一律归「系统通知」。 */
public static String categoryOf(String scene) {
return SCENE_TO_CATEGORY.getOrDefault(normalize(scene), CATEGORY_SYSTEM_NOTICE);
}
/**
* 大类对应的 scene 集合,供查询侧做 scene IN (...) 过滤。
* 空值或未知大类返回空列表,调用方据此跳过该筛选(不报错,避免前端传错值就查不到数据)。
*/
public static List<String> scenesOfCategory(String category) {
String wanted = normalize(category);
if (wanted.isEmpty()) {
return List.of();
}
List<String> scenes = new ArrayList<>();
for (Map.Entry<String, String> entry : SCENE_TO_CATEGORY.entrySet()) {
if (entry.getValue().equals(wanted)) {
scenes.add(entry.getKey());
}
}
return scenes;
}
/** 摘要:未读数 + 最新通知 id(前端轮询判断是否有新通知)。 */
public NotificationSummaryVo summary(Long userId, String audience) {
NotificationSummaryVo vo = new NotificationSummaryVo();
@@ -242,9 +287,11 @@ public class NotificationService {
/**
* 列表筛选:关键字模糊匹配标题/内容;日期按「年月日」闭区间
* (起日 00:00 起含、止日次日 00:00 前不含,避免当天 23:59 漏行)
* (起日 00:00 起含、止日次日 00:00 前不含,避免当天 23:59 漏行)
* 类型按大类映射成 scene 集合过滤。
*/
private void applyFilters(LambdaQueryWrapper<UserNotificationEntity> wrapper, NotificationPageQuery query) {
applyCategoryFilter(wrapper, query.getCategory());
String keyword = normalize(query.getKeyword());
if (!keyword.isEmpty()) {
wrapper.and(nested -> nested.like(UserNotificationEntity::getTitle, keyword)
@@ -258,8 +305,28 @@ public class NotificationService {
}
}
private boolean existsByDedupeKey(String dedupeKey) {
return selectByDedupeKey(dedupeKey) != null;
/**
* 类型筛选:「系统通知」是兜底类,除 scene=system 外还要包含所有未登记 scene
* (否则直接改库塞入的 scene 在筛选时会凭空消失),故表达为「= system 或 不在其它三类里」;
* 未知分类一律不追加条件,避免前端传错值就查不到任何数据。
*/
private void applyCategoryFilter(LambdaQueryWrapper<UserNotificationEntity> wrapper, String category) {
String wanted = normalize(category);
List<String> scenes = wanted.isEmpty() ? List.of() : scenesOfCategory(wanted);
if (scenes.isEmpty()) {
return;
}
if (CATEGORY_SYSTEM_NOTICE.equals(wanted)) {
List<String> classified = new ArrayList<>(SCENE_TO_CATEGORY.keySet());
classified.remove(SCENE_SYSTEM);
wrapper.and(nested -> nested.eq(UserNotificationEntity::getScene, SCENE_SYSTEM)
.or().notIn(UserNotificationEntity::getScene, classified));
return;
}
wrapper.in(UserNotificationEntity::getScene, scenes);
}
private boolean existsByDedupeKey(String dedupeKey) { return selectByDedupeKey(dedupeKey) != null;
}
private UserNotificationEntity selectByDedupeKey(String dedupeKey) {
@@ -273,6 +340,7 @@ public class NotificationService {
NotificationItemVo vo = new NotificationItemVo();
vo.setId(row.getId());
vo.setScene(row.getScene());
vo.setCategory(categoryOf(row.getScene()));
vo.setLevel(row.getLevel());
vo.setTitle(row.getTitle());
vo.setContent(row.getContent());
@@ -282,7 +350,7 @@ public class NotificationService {
return vo;
}
private String normalize(String value) {
private static String normalize(String value) {
return value == null ? "" : value.trim();
}
@@ -34,7 +34,8 @@ class AdminNotificationControllerTest {
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));
controller.page(request, 1L, 20L, false, "余额", NotificationService.CATEGORY_CONFIG_ERROR,
LocalDate.of(2026, 9, 10), LocalDate.of(2026, 9, 13));
verify(notificationService).summary(24L, NotificationService.AUDIENCE_ADMIN);
ArgumentCaptor<NotificationPageQuery> queryCaptor = ArgumentCaptor.forClass(NotificationPageQuery.class);
@@ -44,6 +45,7 @@ class AdminNotificationControllerTest {
assertThat(query.getPageSize()).isEqualTo(20L);
assertThat(query.getOnlyUnread()).isFalse();
assertThat(query.getKeyword()).isEqualTo("余额");
assertThat(query.getCategory()).isEqualTo(NotificationService.CATEGORY_CONFIG_ERROR);
assertThat(query.getStartDate()).isEqualTo(LocalDate.of(2026, 9, 10));
assertThat(query.getEndDate()).isEqualTo(LocalDate.of(2026, 9, 13));
}
@@ -33,7 +33,8 @@ class NotificationControllerTest {
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));
controller.page(request, 2L, 20L, true, "欠费", NotificationService.CATEGORY_TASK_ERROR,
LocalDate.of(2026, 9, 1), LocalDate.of(2026, 9, 13));
verify(notificationService).summary(1095L, NotificationService.AUDIENCE_USER);
ArgumentCaptor<NotificationPageQuery> queryCaptor = ArgumentCaptor.forClass(NotificationPageQuery.class);
@@ -43,6 +44,7 @@ class NotificationControllerTest {
assertThat(query.getPageSize()).isEqualTo(20L);
assertThat(query.getOnlyUnread()).isTrue();
assertThat(query.getKeyword()).isEqualTo("欠费");
assertThat(query.getCategory()).isEqualTo(NotificationService.CATEGORY_TASK_ERROR);
assertThat(query.getStartDate()).isEqualTo(LocalDate.of(2026, 9, 1));
assertThat(query.getEndDate()).isEqualTo(LocalDate.of(2026, 9, 13));
}
@@ -133,7 +133,7 @@ class NotificationServiceTest {
when(mapper.selectList(any())).thenReturn(List.of(first, second));
NotificationPageVo page = service.page(7L, NotificationService.AUDIENCE_USER,
NotificationPageQuery.of(1L, 20L, false, null, null, null));
NotificationPageQuery.of(1L, 20L, false, null, null, null, null));
assertThat(page.getItems()).hasSize(2);
assertThat(page.getItems().get(0).getRead()).isTrue();
@@ -149,7 +149,7 @@ class NotificationServiceTest {
when(mapper.selectCount(any())).thenReturn(0L, 0L);
NotificationPageVo page = service.page(7L, NotificationService.AUDIENCE_ADMIN,
NotificationPageQuery.of(0L, 500L, true, null, null, null));
NotificationPageQuery.of(0L, 500L, true, null, null, null, null));
assertThat(page.getItems()).isEmpty();
assertThat(page.getPage()).isEqualTo(1L);
@@ -204,7 +204,7 @@ class NotificationServiceTest {
when(mapper.selectList(any())).thenReturn(List.of());
service.page(24L, NotificationService.AUDIENCE_ADMIN,
NotificationPageQuery.of(1L, 20L, true, null, null, null));
NotificationPageQuery.of(1L, 20L, true, null, null, null, null));
ArgumentCaptor<LambdaQueryWrapper<UserNotificationEntity>> countCaptor =
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
@@ -224,7 +224,7 @@ class NotificationServiceTest {
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)));
1L, 20L, false, "余额不足", null, LocalDate.of(2026, 9, 10), LocalDate.of(2026, 9, 13)));
ArgumentCaptor<LambdaQueryWrapper<UserNotificationEntity>> listCaptor =
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
@@ -235,6 +235,87 @@ class NotificationServiceTest {
.contains("%余额不足%", LocalDateTime.of(2026, 9, 10, 0, 0), LocalDateTime.of(2026, 9, 14, 0, 0));
}
/** 类型大类映射:scene 归并成四类,未知 scene 兜底「系统通知」,保证筛选互斥且完备。 */
@Test
void categoryOfMapsScenesToUserVisibleGroups() {
assertThat(NotificationService.categoryOf(NotificationService.SCENE_TASK_FAILED))
.isEqualTo(NotificationService.CATEGORY_TASK_ERROR);
assertThat(NotificationService.categoryOf(NotificationService.SCENE_SERVICE_DOWN))
.isEqualTo(NotificationService.CATEGORY_SYSTEM_ERROR);
assertThat(NotificationService.categoryOf(NotificationService.SCENE_MAIXIANG_ANOMALY))
.isEqualTo(NotificationService.CATEGORY_SYSTEM_ERROR);
assertThat(NotificationService.categoryOf(NotificationService.SCENE_SECRET_BALANCE))
.isEqualTo(NotificationService.CATEGORY_CONFIG_ERROR);
assertThat(NotificationService.categoryOf(NotificationService.SCENE_SECRET_INVALID))
.isEqualTo(NotificationService.CATEGORY_CONFIG_ERROR);
assertThat(NotificationService.categoryOf(NotificationService.SCENE_SYSTEM))
.isEqualTo(NotificationService.CATEGORY_SYSTEM_NOTICE);
// 历史遗留/新增未登记的 scene 不能被筛丢
assertThat(NotificationService.categoryOf("legacy_scene"))
.isEqualTo(NotificationService.CATEGORY_SYSTEM_NOTICE);
assertThat(NotificationService.categoryOf(null))
.isEqualTo(NotificationService.CATEGORY_SYSTEM_NOTICE);
}
/** 类型筛选落到 SQL 上是 scene IN (...);空/未知分类不追加条件,避免前端传错值就查不到数据。 */
@Test
@SuppressWarnings("unchecked")
void pageAppliesCategoryAsSceneInFilter() {
when(mapper.selectCount(any())).thenReturn(1L, 0L);
when(mapper.selectList(any())).thenReturn(List.of());
service.page(24L, NotificationService.AUDIENCE_USER, NotificationPageQuery.of(
1L, 20L, false, null, NotificationService.CATEGORY_SYSTEM_ERROR, null, null));
ArgumentCaptor<LambdaQueryWrapper<UserNotificationEntity>> listCaptor =
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(mapper).selectList(listCaptor.capture());
LambdaQueryWrapper<UserNotificationEntity> wrapper = listCaptor.getValue();
assertThat(wrapper.getSqlSegment()).contains("scene");
assertThat(wrapper.getParamNameValuePairs().values())
.contains(NotificationService.SCENE_SERVICE_DOWN, NotificationService.SCENE_MAIXIANG_ANOMALY);
assertThat(NotificationService.scenesOfCategory(null)).isEmpty();
assertThat(NotificationService.scenesOfCategory("")).isEmpty();
assertThat(NotificationService.scenesOfCategory("not_a_category")).isEmpty();
}
/** 「系统通知」是兜底类:除 scene=system 外还要纳入未登记 scene,否则那些通知筛选时会消失。 */
@Test
@SuppressWarnings("unchecked")
void pageTreatsSystemNoticeCategoryAsCatchAll() {
when(mapper.selectCount(any())).thenReturn(1L, 0L);
when(mapper.selectList(any())).thenReturn(List.of());
service.page(24L, NotificationService.AUDIENCE_USER, NotificationPageQuery.of(
1L, 20L, false, null, NotificationService.CATEGORY_SYSTEM_NOTICE, null, null));
ArgumentCaptor<LambdaQueryWrapper<UserNotificationEntity>> listCaptor =
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(mapper).selectList(listCaptor.capture());
LambdaQueryWrapper<UserNotificationEntity> wrapper = listCaptor.getValue();
assertThat(wrapper.getSqlSegment()).contains("scene");
assertThat(wrapper.getParamNameValuePairs().values())
.contains(NotificationService.SCENE_SYSTEM, NotificationService.SCENE_TASK_FAILED);
}
/** 列表项必须带类型大类,前端据此展示与筛选,不必各自维护一份 scene 映射。 */
@Test
void pageItemsCarryCategory() {
UserNotificationEntity row = new UserNotificationEntity();
row.setId(9L);
row.setScene(NotificationService.SCENE_SECRET_BALANCE);
when(mapper.selectCount(any())).thenReturn(1L, 0L);
when(mapper.selectList(any())).thenReturn(List.of(row));
NotificationPageVo page = service.page(7L, NotificationService.AUDIENCE_USER,
NotificationPageQuery.of(1L, 20L, false, null, null, null, null));
assertThat(page.getItems()).hasSize(1);
assertThat(page.getItems().get(0).getCategory())
.isEqualTo(NotificationService.CATEGORY_CONFIG_ERROR);
}
@Test
@SuppressWarnings("unchecked")
void summaryAndUnreadCountScopeByReceiverAndAudience() {