feat(站内通知): 铃铛支持按类型大类筛选(系统异常/任务异常/配置异常/系统通知)
原来铃铛只能按关键字和日期筛,任务失败、密钥欠费、麦象异常全混在一起, 用户没法只看自己关心的那类。 后端 NotificationService 增加 scene→大类映射(未知 scene 归「系统通知」兜底), 列表接口新增 category 参数,落到 SQL 是 scene IN (...);「系统通知」作兜底类 还要纳入未登记的 scene,故表达为「= system 或 不在其它三类里」。列表项返回 category,前端不必各自维护一份 scene 映射。前后端两个通知接口同步加参数。 前端铃铛筛选区新增一行类型 chip(全部 + 四类),与关键字、日期一起参与 hasFilter 与重置;空分类不下发,避免后端查询落空。
This commit is contained in:
+6
-2
@@ -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")
|
||||
|
||||
+6
-2
@@ -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")
|
||||
|
||||
+7
-3
@@ -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;
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+75
-7
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -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));
|
||||
}
|
||||
|
||||
+3
-1
@@ -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));
|
||||
}
|
||||
|
||||
+85
-4
@@ -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() {
|
||||
|
||||
@@ -11,11 +11,33 @@ export type NotificationScene =
|
||||
| 'maixiang_anomaly'
|
||||
| 'system'
|
||||
|
||||
/**
|
||||
* 通知类型大类:后端按 scene 归并出的四类,铃铛筛选与标签都用它。
|
||||
* 未知/历史遗留 scene 由后端兜底归入 system_notice。
|
||||
*/
|
||||
export type NotificationCategory =
|
||||
| 'system_error'
|
||||
| 'task_error'
|
||||
| 'config_error'
|
||||
| 'system_notice'
|
||||
|
||||
/** 类型筛选项(顺序即下拉顺序):全部 + 四个大类。 */
|
||||
export const NOTIFICATION_CATEGORY_OPTIONS: ReadonlyArray<{
|
||||
value: NotificationCategory
|
||||
label: string
|
||||
}> = [
|
||||
{ value: 'system_error', label: '系统异常' },
|
||||
{ value: 'task_error', label: '任务异常' },
|
||||
{ value: 'config_error', label: '配置异常' },
|
||||
{ value: 'system_notice', label: '系统通知' },
|
||||
]
|
||||
|
||||
export type NotificationLevel = 'info' | 'warning' | 'error'
|
||||
|
||||
export interface NotificationItem {
|
||||
id: number
|
||||
scene: string
|
||||
category: string
|
||||
level: string
|
||||
title: string
|
||||
content: string
|
||||
@@ -44,19 +66,24 @@ export function fetchNotificationSummary() {
|
||||
)
|
||||
}
|
||||
|
||||
/** 列表查询参数:关键字匹配标题/内容;startDate/endDate 为年月日闭区间(yyyy-MM-dd)。 */
|
||||
/**
|
||||
* 列表查询参数:关键字匹配标题/内容;category 按类型大类筛选;
|
||||
* startDate/endDate 为年月日闭区间(yyyy-MM-dd)。
|
||||
*/
|
||||
export interface NotificationListParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
onlyUnread?: boolean
|
||||
keyword?: string
|
||||
category?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表查询参数:关键字匹配标题/内容;startDate/endDate 为年月日闭区间(yyyy-MM-dd)。
|
||||
* 空关键字/日期一律不下发(空串会让后端日期绑定失败返回 400)。
|
||||
* 列表查询参数:关键字匹配标题/内容;category 按类型大类筛选;
|
||||
* startDate/endDate 为年月日闭区间(yyyy-MM-dd)。
|
||||
* 空关键字/日期/分类一律不下发(空串会让后端日期绑定失败返回 400)。
|
||||
*/
|
||||
export function buildNotificationListQuery(
|
||||
params: NotificationListParams = {},
|
||||
@@ -70,6 +97,10 @@ export function buildNotificationListQuery(
|
||||
if (keyword) {
|
||||
query.keyword = keyword
|
||||
}
|
||||
const category = (params.category ?? '').trim()
|
||||
if (category) {
|
||||
query.category = category
|
||||
}
|
||||
if (params.startDate) {
|
||||
query.startDate = params.startDate
|
||||
}
|
||||
|
||||
@@ -104,6 +104,26 @@
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="bell-category-chips" role="group" aria-label="按类型筛选">
|
||||
<button
|
||||
type="button"
|
||||
class="bell-chip"
|
||||
:class="{ 'is-active': !category }"
|
||||
@click="selectCategory('')"
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
<button
|
||||
v-for="option in NOTIFICATION_CATEGORY_OPTIONS"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="bell-chip"
|
||||
:class="{ 'is-active': category === option.value }"
|
||||
@click="selectCategory(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="bell-search-days">
|
||||
<el-date-picker
|
||||
:model-value="dateRange"
|
||||
@@ -154,7 +174,7 @@
|
||||
</svg>
|
||||
</span>
|
||||
<p class="bell-state-text">{{ hasFilter ? '没有符合条件的通知' : '暂无通知' }}</p>
|
||||
<p v-if="hasFilter" class="bell-state-sub">试试调整搜索词或时间范围</p>
|
||||
<p v-if="hasFilter" class="bell-state-sub">试试调整类型、搜索词或时间范围</p>
|
||||
</div>
|
||||
|
||||
<ul v-else class="bell-list">
|
||||
@@ -262,6 +282,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import {
|
||||
NOTIFICATION_CATEGORY_OPTIONS,
|
||||
fetchNotificationList,
|
||||
fetchNotificationSummary,
|
||||
markAllNotificationsRead,
|
||||
@@ -306,6 +327,8 @@ const loadError = ref('')
|
||||
const keyword = ref('')
|
||||
/** 日期区间(el-date-picker daterange,YYYY-MM-DD 字符串;未选为 null)。 */
|
||||
const dateRange = ref<[string, string] | null>(null)
|
||||
/** 类型大类(system_error/task_error/config_error/system_notice);空串表示不按类型筛选。 */
|
||||
const category = ref('')
|
||||
|
||||
const badgeText = computed(() => formatUnreadBadge(unreadCount.value))
|
||||
const unreadChipText = computed(() =>
|
||||
@@ -313,7 +336,9 @@ const unreadChipText = computed(() =>
|
||||
)
|
||||
const groupedItems = computed(() => groupNotificationsByDay(items.value))
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||
const hasFilter = computed(() => Boolean(keyword.value.trim() || dateRange.value))
|
||||
const hasFilter = computed(() =>
|
||||
Boolean(keyword.value.trim() || dateRange.value || category.value),
|
||||
)
|
||||
|
||||
let pollTimer: number | null = null
|
||||
let searchTimer: number | null = null
|
||||
@@ -372,6 +397,7 @@ async function loadPage(targetPage: number) {
|
||||
page: targetPage,
|
||||
pageSize: pageSize.value,
|
||||
keyword: keyword.value,
|
||||
category: category.value,
|
||||
...normalizedDayRange(),
|
||||
})
|
||||
const list = Array.isArray(result?.items) ? result.items : []
|
||||
@@ -429,9 +455,20 @@ function clearKeyword() {
|
||||
void loadPage(1)
|
||||
}
|
||||
|
||||
/** 类型筛选:重复点同一个不重复请求;切换后回到第一页。 */
|
||||
function selectCategory(value: string) {
|
||||
if (category.value === value) {
|
||||
return
|
||||
}
|
||||
category.value = value
|
||||
console.log('[notification] 类型筛选切换为', value || '全部')
|
||||
void loadPage(1)
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
keyword.value = ''
|
||||
dateRange.value = null
|
||||
category.value = ''
|
||||
void loadPage(1)
|
||||
}
|
||||
|
||||
@@ -799,6 +836,38 @@ onUnmounted(() => {
|
||||
background: var(--bell-text-mute);
|
||||
}
|
||||
|
||||
/* 类型筛选 chip 行:比下拉更省纵向空间,与深色控制台风格一致 */
|
||||
.bell-category-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bell-chip {
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--bell-border);
|
||||
border-radius: 12px;
|
||||
background: var(--bell-input-bg);
|
||||
color: var(--bell-text-mute);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.bell-chip:hover {
|
||||
color: var(--bell-text);
|
||||
border-color: var(--bell-accent);
|
||||
}
|
||||
|
||||
.bell-chip.is-active {
|
||||
border-color: var(--bell-accent);
|
||||
background: var(--bell-accent-soft);
|
||||
color: var(--bell-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bell-search-days {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
readLastNotifiedId,
|
||||
writeLastNotifiedId,
|
||||
} from '../src/shared/utils/notification-bell.ts'
|
||||
import { buildNotificationListQuery } from '../src/shared/api/types/modules/notification.ts'
|
||||
import {
|
||||
NOTIFICATION_CATEGORY_OPTIONS,
|
||||
buildNotificationListQuery,
|
||||
} from '../src/shared/api/types/modules/notification.ts'
|
||||
|
||||
function createStorage() {
|
||||
const store = new Map<string, string>()
|
||||
@@ -133,13 +136,14 @@ test('groupNotificationsByDay 时间缺失/非法归入未知时间组', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('buildNotificationListQuery:关键字去空白、空条件不下发、日期原样透传', () => {
|
||||
test('buildNotificationListQuery:关键字去空白、空条件不下发、分类与日期原样透传', () => {
|
||||
assert.deepEqual(buildNotificationListQuery(), { page: 1, pageSize: 20, onlyUnread: 'false' })
|
||||
|
||||
const query = buildNotificationListQuery({
|
||||
page: 2,
|
||||
pageSize: 10,
|
||||
keyword: ' 余额不足 ',
|
||||
category: 'config_error',
|
||||
startDate: '2026-09-10',
|
||||
endDate: '2026-09-13',
|
||||
})
|
||||
@@ -148,13 +152,31 @@ test('buildNotificationListQuery:关键字去空白、空条件不下发、日
|
||||
pageSize: 10,
|
||||
onlyUnread: 'false',
|
||||
keyword: '余额不足',
|
||||
category: 'config_error',
|
||||
startDate: '2026-09-10',
|
||||
endDate: '2026-09-13',
|
||||
})
|
||||
|
||||
// 面板清空筛选后不能带空串日期(后端日期绑定会 400)
|
||||
const cleared = buildNotificationListQuery({ keyword: ' ', startDate: '', endDate: '' })
|
||||
// 面板清空筛选后不能带空串日期(后端日期绑定会 400),空分类同样不下发
|
||||
const cleared = buildNotificationListQuery({
|
||||
keyword: ' ',
|
||||
category: ' ',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
})
|
||||
assert.equal('keyword' in cleared, false)
|
||||
assert.equal('category' in cleared, false)
|
||||
assert.equal('startDate' in cleared, false)
|
||||
assert.equal('endDate' in cleared, false)
|
||||
})
|
||||
|
||||
/** 类型筛选项必须与后端四个大类逐一对应,漏一个用户就永远筛不到那类通知。 */
|
||||
test('类型筛选项覆盖后端四个大类且标签非空', () => {
|
||||
assert.deepEqual(
|
||||
NOTIFICATION_CATEGORY_OPTIONS.map((option) => option.value),
|
||||
['system_error', 'task_error', 'config_error', 'system_notice'],
|
||||
)
|
||||
for (const option of NOTIFICATION_CATEGORY_OPTIONS) {
|
||||
assert.ok(option.label.trim().length > 0, `分类 ${option.value} 缺中文标签`)
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user