feat(站内通知): 铃铛面板支持时间/内容搜索、按天分组与分页

- 列表接口加 keyword(标题/内容模糊)与 startDate/endDate(年月日闭区间)参数,
  两端控制器透传,服务端补筛选日志
- 两端铃铛面板:搜索框(防抖 300ms)+ 日期区间 + 按年月日分组 + 翻页,
  面板改 Teleport 到 body(挂在顶栏时会被页面 el-select 压住,提 z-index 无效)
- 固化可见范围回归测试:超管全量/管理员只看本组/普通用户只看自己,
  含读写两侧的 user_id+audience 裁剪断言与两端控制器身份来源断言
This commit is contained in:
2026-09-13 23:59:06 +08:00
parent 9ffd68bae5
commit ff1ffbbfa3
18 changed files with 1426 additions and 136 deletions
@@ -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")
@@ -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")
@@ -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;
}
}
@@ -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;
}