feat(认证/通知): 单设备登录互踢 + 站内通知铃铛系统

- 单设备登录:登录成功即 last-login-wins 绑定 users.machine;非超管旧 token
  在下一次受保护请求抛 4011 下线,超管豁免;仅认 token 内签名 deviceId,不用请求头。
  前端两端接入踢下线跳转(?kicked=1 提示),V118 清空历史 machine
- 站内通知:新增 notification 模块(任务失败扫描 / 密钥欠费 / 下游服务探测三类来源),
  前后台铃铛组件 + 轮询;后台列表按主管数据范围(UserDataScopeSupport)过滤;V116 建表

均已于 2026-09-13 部署上线,此次补提交源码(此前仅存在于已部署 JAR/构建产物中)
This commit is contained in:
2026-09-13 23:08:22 +08:00
parent a0f6582914
commit b70557a077
53 changed files with 3982 additions and 101 deletions
@@ -71,6 +71,7 @@ public class AdminApiGuardFilter extends OncePerRequestFilter {
*/
private static final String[] SELF_SERVICE_PREFIXES = {
"/api/user-secrets",
"/api/notifications",
};
private final AdminAuthSupport adminAuthSupport;
@@ -0,0 +1,46 @@
package com.nanri.aiimage.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* 站内通知(铃铛)扫描与探测配置:
* 任务失败扫描、下游服务健康探测、已读通知保留期。
*/
@Data
@ConfigurationProperties(prefix = "aiimage.notification")
public class NotificationProperties {
/** 扫描总开关:关闭后任务失败扫描与服务探测都不执行(应急降噪)。 */
private boolean scanEnabled = true;
/** 扫描间隔(毫秒),默认 5 分钟(任务失败聚合按小时去重,高频扫描不会重复提醒)。 */
private long scanIntervalMs = 5 * 60 * 1000L;
/** 首次执行延迟(毫秒),默认 2 分钟,避开启动阶段的数据库压力。 */
private long scanInitialDelayMs = 2 * 60 * 1000L;
/** 任务失败扫描开关。 */
private boolean taskScanEnabled = true;
/** 任务失败回看窗口(分钟):窗口内进入失败终态的任务参与聚合。 */
private int taskFailedWindowMinutes = 60;
/** 单轮任务扫描最多处理条数(超出下一轮继续,避免大表拖垮扫描)。 */
private int taskScanMaxRows = 1000;
/** 服务健康探测开关。 */
private boolean serviceProbeEnabled = true;
/** 品牌检测服务地址(主机A 15126,探测 /api/version);留空=跳过该项探测。 */
private String brandServiceUrl = "";
/** 跟价任务 API 地址(主机B 18960,探测根路径);留空=跳过该项探测。 */
private String priceTrackApiUrl = "";
/** jikip 代理接口探测开关(复用余额查询接口判活,使用 user-secret 的 jikip 配置)。 */
private boolean jikipProbeEnabled = true;
/** 已读通知保留天数(超期自动清理),默认 90 天。 */
private int readRetentionDays = 90;
}
@@ -4,6 +4,6 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableConfigurationProperties({OssProperties.class, TransientStorageProperties.class, StorageProperties.class, BrandProgressProperties.class, DeleteBrandProgressProperties.class, BrandCheckProperties.class, ZiniaoProperties.class, ModuleCleanupProperties.class, TaskPressureProperties.class, TaskImageCacheCleanupProperties.class, AppearancePatentProperties.class, SimilarAsinProperties.class, ImageVideoProperties.class, InstanceRoutingProperties.class, CapacityPlanProperties.class, UserSecretProperties.class})
@EnableConfigurationProperties({OssProperties.class, TransientStorageProperties.class, StorageProperties.class, BrandProgressProperties.class, DeleteBrandProgressProperties.class, BrandCheckProperties.class, ZiniaoProperties.class, ModuleCleanupProperties.class, TaskPressureProperties.class, TaskImageCacheCleanupProperties.class, AppearancePatentProperties.class, SimilarAsinProperties.class, ImageVideoProperties.class, InstanceRoutingProperties.class, CapacityPlanProperties.class, UserSecretProperties.class, NotificationProperties.class})
public class PropertiesConfig {
}
@@ -36,4 +36,10 @@ public class UserSecretProperties {
/** jikip 用户 ID(余量查询参数)。 */
private String jikipUserId = "";
/**
* 巡检发现欠费/密钥失效时是否推送站内通知(桌面端用户 + 后台管理员);
* 关闭后巡检只更新检测状态、不发通知(应急降噪开关)。
*/
private boolean notifyEnabled = true;
}
@@ -2,6 +2,7 @@ package com.nanri.aiimage.modules.admin.support;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.auth.service.JwtService;
import com.nanri.aiimage.modules.auth.support.DeviceSessionPolicy;
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
import io.jsonwebtoken.Claims;
@@ -52,6 +53,12 @@ public class AdminAuthSupport {
if (user == null) {
throw new BusinessException(401, "用户不存在");
}
// 单设备登录:被新设备顶下线的旧 token 在此统一拦截(全站 requireUser 调用点自动生效)
if (authProperties.isSingleDeviceEnabled()) {
DeviceSessionPolicy.assertSameDevice(user.getMachine(), DeviceSessionPolicy.claimDeviceId(claims),
DeviceSessionPolicy.isSuperAdmin(user.getRole(), user.getIsAdmin(), user.getCreatedById()),
user.getId(), user.getUsername());
}
return user;
}
@@ -70,18 +77,7 @@ public class AdminAuthSupport {
if (user == null) {
return null;
}
String storedRole = user.getRole() == null ? "" : user.getRole().trim().toLowerCase();
if ("super_admin".equals(storedRole)) {
return "super_admin";
}
if ("admin".equals(storedRole)) {
return "admin";
}
boolean isAdminFlag = user.getIsAdmin() != null && user.getIsAdmin() == 1;
if (storedRole.isEmpty() && isAdminFlag) {
return user.getCreatedById() == null ? "super_admin" : "admin";
}
return null;
return DeviceSessionPolicy.resolveRole(user.getRole(), user.getIsAdmin(), user.getCreatedById());
}
/** JWT 优先;无 JWT 时以可信内部代理身份(X-Internal-Token + operatorId)回退,仍要求管理员角色。 */
@@ -14,4 +14,6 @@ public class AuthProperties {
private String cookieName = "aiimage_token";
private boolean cookieSecure = false;
private String cookieSameSite = "Lax";
/** 单设备登录(互踢)总开关:关闭后恢复为多设备同时在线(回滚用)。 */
private boolean singleDeviceEnabled = true;
}
@@ -8,6 +8,7 @@ import com.nanri.aiimage.modules.auth.mapper.LoginUserMapper;
import com.nanri.aiimage.modules.auth.model.dto.LoginRequest;
import com.nanri.aiimage.modules.auth.model.entity.LoginUserEntity;
import com.nanri.aiimage.modules.auth.model.vo.LoginResultVo;
import com.nanri.aiimage.modules.auth.support.DeviceSessionPolicy;
import com.nanri.aiimage.modules.auth.util.WerkzeugPasswordEncoder;
import com.nanri.aiimage.modules.permission.service.PermissionMenuService;
import io.jsonwebtoken.Claims;
@@ -48,32 +49,14 @@ public class AuthService {
}
boolean isAdmin = user.getIsAdmin() != null && user.getIsAdmin() == 1;
// ---- 设备绑定逻辑临时停用(便于多设备 / 浏览器联调)----
// 原逻辑:首次登录写入 machine;设备指纹变化时把 machine 重绑到当前设备。
// 注释期间登录不写、不校验 machine,任何设备均可登录,仅保留日志便于排查。
/*
String stored = user.getMachine() == null ? "" : user.getMachine().trim();
if (stored.isEmpty()) {
// 单设备登录:登录成功即把账号绑定到当前设备(last-login-wins),原设备下一次请求被顶下线
if (authProperties.isSingleDeviceEnabled()) {
DeviceSessionPolicy.logBindOnLogin(user.getMachine(), deviceId, user.getId(), user.getUsername());
loginUserMapper.update(null, new LambdaUpdateWrapper<LoginUserEntity>()
.eq(LoginUserEntity::getId, user.getId())
.set(LoginUserEntity::getMachine, deviceId));
stored = deviceId;
log.info("[auth] first-login bind userId={} device={}", user.getId(), deviceId);
} else if (!stored.equals(deviceId)) {
// 设备指纹变化(换电脑/重装/清理注册表)会锁死账号,密码已验证通过,
// 直接重新绑定到当前设备,避免账号被锁、数据因重建账号而丢失。
loginUserMapper.update(null, new LambdaUpdateWrapper<LoginUserEntity>()
.eq(LoginUserEntity::getId, user.getId())
.set(LoginUserEntity::getMachine, deviceId));
log.warn("[auth] device rebound on login userId={} old={} new={}",
user.getId(), stored, deviceId);
} else {
log.info("[auth] device match userId={} isAdmin={} device={}",
user.getId(), isAdmin, deviceId);
}
*/
log.info("[auth] login (device-bind disabled) userId={} isAdmin={} device={}",
user.getId(), isAdmin, deviceId);
log.info("[auth] login userId={} isAdmin={} device={}", user.getId(), isAdmin, deviceId);
return buildResult(user, deviceId, isAdmin);
}
@@ -97,31 +80,19 @@ public class AuthService {
throw new BusinessException(401, "用户不存在");
}
boolean isAdmin = user.getIsAdmin() != null && user.getIsAdmin() == 1;
String stored = user.getMachine() == null ? "" : user.getMachine().trim();
String device = trim(currentDeviceId);
if (device.isEmpty()) {
// 没传设备 ID 时,回落到 token 内 deviceId
Object claimDevice = claims.get("deviceId");
device = claimDevice == null ? "" : claimDevice.toString().trim();
String claimDeviceId = DeviceSessionPolicy.claimDeviceId(claims);
// 单设备登录:不拦截的话,被顶下线的旧 token 会在这里续期「复活」
if (authProperties.isSingleDeviceEnabled()) {
DeviceSessionPolicy.assertSameDevice(user.getMachine(), claimDeviceId,
DeviceSessionPolicy.isSuperAdmin(user.getRole(), user.getIsAdmin(), user.getCreatedById()),
user.getId(), user.getUsername());
}
// ---- 设备绑定逻辑临时停用:设备不一致不再拒绝登录态,仅保留告警日志 ----
/*
if (!stored.isEmpty() && !device.isEmpty() && !stored.equals(device)) {
if (isAdmin) {
log.warn("[auth] check_login device mismatch but admin bypass userId={} stored={} current={}",
user.getId(), stored, device);
} else {
log.warn("[auth] check_login device mismatch reject userId={} stored={} current={}",
user.getId(), stored, device);
throw new BusinessException(401, "当前设备与首次登录设备不一致");
}
String headerDevice = trim(currentDeviceId);
if (!headerDevice.isEmpty() && !claimDeviceId.isEmpty() && !headerDevice.equals(claimDeviceId)) {
log.warn("[auth] check_login 请求头设备与登录态内设备不一致(以登录态为准)userId={} headerDevice={} loginDevice={}",
user.getId(), headerDevice, claimDeviceId);
}
*/
if (!stored.isEmpty() && !device.isEmpty() && !stored.equals(device)) {
log.warn("[auth] check_login device mismatch (bypass) userId={} stored={} current={}",
user.getId(), stored, device);
}
return buildResult(user, stored.isEmpty() ? device : stored, isAdmin);
return buildResult(user, claimDeviceId, isAdmin);
}
public ResponseCookie buildAuthCookie(String token) {
@@ -0,0 +1,90 @@
package com.nanri.aiimage.modules.auth.support;
import com.nanri.aiimage.common.exception.BusinessException;
import io.jsonwebtoken.Claims;
import lombok.extern.slf4j.Slf4j;
/**
* 单设备登录(互踢)策略。
*
* <p>账号当前绑定的设备存放在 users.machine(登录成功即覆盖,last-login-wins);
* 非超管账号仅允许「token 内签名的 deviceId」与绑定设备一致的请求通过,
* 被新设备顶下线的旧 token 在下一次受保护请求时抛 4011。</p>
*
* <p>校验只认 token 内签名的 deviceId,绝不使用 X-Device-Id 请求头(头是客户端可控的)。</p>
*/
@Slf4j
public final class DeviceSessionPolicy {
/** 账号已在其他设备登录(前端据此提示并下线本设备)。 */
public static final int CODE_KICKED = 4011;
private DeviceSessionPolicy() {
}
/** 计算用户管理角色:super_admin / admin / null(含老数据 role 为空时按 created_by_id 推断)。 */
public static String resolveRole(String role, Integer isAdmin, Long createdById) {
String storedRole = role == null ? "" : role.trim().toLowerCase();
if ("super_admin".equals(storedRole)) {
return "super_admin";
}
if ("admin".equals(storedRole)) {
return "admin";
}
boolean isAdminFlag = isAdmin != null && isAdmin == 1;
if (storedRole.isEmpty() && isAdminFlag) {
return createdById == null ? "super_admin" : "admin";
}
return null;
}
/** 是否超级管理员(互踢的唯一豁免角色)。 */
public static boolean isSuperAdmin(String role, Integer isAdmin, Long createdById) {
return "super_admin".equals(resolveRole(role, isAdmin, createdById));
}
/** 从 JWT claims 中提取签名的设备号;缺失返回空串。 */
public static String claimDeviceId(Claims claims) {
Object raw = claims == null ? null : claims.get("deviceId");
return raw == null ? "" : raw.toString().trim();
}
/**
* 校验请求携带的 token 是否仍属于账号当前绑定的设备。
* 超管豁免;machine 为空(尚未绑定)放行;不匹配抛 4011。
*/
public static void assertSameDevice(String storedMachine, String claimDeviceId, boolean exempt,
Long userId, String username) {
if (exempt) {
return;
}
String bound = storedMachine == null ? "" : storedMachine.trim();
if (bound.isEmpty()) {
// 尚未绑定(首次登录前 / V118 清空后首个登录前),不做限制
return;
}
String claimed = claimDeviceId == null ? "" : claimDeviceId.trim();
if (claimed.isEmpty()) {
log.warn("[auth] 单设备登录校验:token 缺少设备标识,拒绝 userId={} username={}", userId, username);
throw new BusinessException(401, "登录态无效");
}
if (!bound.equals(claimed)) {
log.warn("[auth] 单设备登录拦截:用户 {} 已被其他设备顶下线,token设备={} 当前绑定设备={}",
userId, claimed, bound);
throw new BusinessException(CODE_KICKED, "该账号已在其他设备登录,本设备已下线");
}
}
/** 登录绑定时的中文日志:首次绑定 / 换设备(顶下线)/ 同设备重登,便于线上排查互踢来源。 */
public static void logBindOnLogin(String previousMachine, String deviceId, Long userId, String username) {
String previous = previousMachine == null ? "" : previousMachine.trim();
if (previous.isEmpty()) {
log.info("[auth] 单设备登录:用户 {}({})首次绑定设备 {}", userId, username, deviceId);
} else if (!previous.equals(deviceId)) {
log.warn("[auth] 单设备登录:用户 {}({})在设备 {} 登录,原设备 {} 已被顶下线",
userId, username, deviceId, previous);
} else {
log.info("[auth] 单设备登录:用户 {}{})同设备重新登录 device={}", userId, username, deviceId);
}
}
}
@@ -0,0 +1,74 @@
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.vo.NotificationPageVo;
import com.nanri.aiimage.modules.notification.model.vo.NotificationSummaryVo;
import com.nanri.aiimage.modules.notification.service.NotificationService;
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
import io.swagger.v3.oas.annotations.Operation;
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.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 后台站内通知(铃铛):超管与管理员共用,按登录管理员维度读写 audience=admin 的通知;
* 可见范围(全量 or 分组内成员)在通知生成时已按数据权限过滤。
*/
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/admin/notifications")
@Tag(name = "站内通知(后台)", description = "管理端铃铛未读数、通知列表与已读标记。")
public class AdminNotificationController {
private final NotificationService notificationService;
private final AdminAuthSupport adminAuthSupport;
@GetMapping("/summary")
@Operation(summary = "未读数与最新通知 id")
public ApiResponse<NotificationSummaryVo> summary(HttpServletRequest request) {
Long userId = currentAdminId(request);
return ApiResponse.success(notificationService.summary(userId, NotificationService.AUDIENCE_ADMIN));
}
@GetMapping
@Operation(summary = "通知分页列表")
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) {
Long userId = currentAdminId(request);
return ApiResponse.success(notificationService.page(
userId, NotificationService.AUDIENCE_ADMIN, page, pageSize, Boolean.TRUE.equals(onlyUnread)));
}
@PostMapping("/{id}/read")
@Operation(summary = "标记单条已读")
public ApiResponse<Boolean> markRead(HttpServletRequest request,
@Parameter(description = "通知ID", required = true) @PathVariable Long id) {
Long userId = currentAdminId(request);
return ApiResponse.success("已标记已读",
notificationService.markRead(userId, NotificationService.AUDIENCE_ADMIN, id));
}
@PostMapping("/read-all")
@Operation(summary = "全部标记已读")
public ApiResponse<Integer> markAllRead(HttpServletRequest request) {
Long userId = currentAdminId(request);
int updated = notificationService.markAllRead(userId, NotificationService.AUDIENCE_ADMIN);
return ApiResponse.success("已全部标记已读", updated);
}
private Long currentAdminId(HttpServletRequest request) {
AdminUserEntity operator = adminAuthSupport.requireAdmin(request);
return operator.getId();
}
}
@@ -0,0 +1,74 @@
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.vo.NotificationPageVo;
import com.nanri.aiimage.modules.notification.model.vo.NotificationSummaryVo;
import com.nanri.aiimage.modules.notification.service.NotificationService;
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
import io.swagger.v3.oas.annotations.Operation;
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.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 桌面端站内通知(铃铛):当前登录用户维度,用户身份一律从 JWT 解析;
* 只读写 audience=user 的通知,不感知后台管理员通知。
*/
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/notifications")
@Tag(name = "站内通知(桌面端)", description = "铃铛未读数、通知列表与已读标记。")
public class NotificationController {
private final NotificationService notificationService;
private final AdminAuthSupport adminAuthSupport;
@GetMapping("/summary")
@Operation(summary = "未读数与最新通知 id", description = "桌面端铃铛轮询用;latestId 增大表示有新通知。")
public ApiResponse<NotificationSummaryVo> summary(HttpServletRequest request) {
Long userId = currentUserId(request);
return ApiResponse.success(notificationService.summary(userId, NotificationService.AUDIENCE_USER));
}
@GetMapping
@Operation(summary = "通知分页列表", description = "onlyUnread=true 时只返回未读;附带未读总数。")
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) {
Long userId = currentUserId(request);
return ApiResponse.success(notificationService.page(
userId, NotificationService.AUDIENCE_USER, page, pageSize, Boolean.TRUE.equals(onlyUnread)));
}
@PostMapping("/{id}/read")
@Operation(summary = "标记单条已读")
public ApiResponse<Boolean> markRead(HttpServletRequest request,
@Parameter(description = "通知ID", required = true) @PathVariable Long id) {
Long userId = currentUserId(request);
return ApiResponse.success("已标记已读",
notificationService.markRead(userId, NotificationService.AUDIENCE_USER, id));
}
@PostMapping("/read-all")
@Operation(summary = "全部标记已读")
public ApiResponse<Integer> markAllRead(HttpServletRequest request) {
Long userId = currentUserId(request);
int updated = notificationService.markAllRead(userId, NotificationService.AUDIENCE_USER);
return ApiResponse.success("已全部标记已读", updated);
}
private Long currentUserId(HttpServletRequest request) {
AdminUserEntity me = adminAuthSupport.requireUser(request);
return me.getId();
}
}
@@ -0,0 +1,9 @@
package com.nanri.aiimage.modules.notification.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.nanri.aiimage.modules.notification.model.entity.UserNotificationEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface UserNotificationMapper extends BaseMapper<UserNotificationEntity> {
}
@@ -0,0 +1,25 @@
package com.nanri.aiimage.modules.notification.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("biz_user_notification")
public class UserNotificationEntity {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
private String audience;
private String scene;
private String level;
private String title;
private String content;
private String dedupeKey;
private LocalDateTime readAt;
private LocalDateTime createdAt;
}
@@ -0,0 +1,22 @@
package com.nanri.aiimage.modules.notification.model.vo;
import lombok.Data;
import java.time.LocalDateTime;
/** 单条通知(桌面端与后台共用结构)。 */
@Data
public class NotificationItemVo {
private Long id;
/** 场景:secret_balance/secret_invalid/task_failed/service_down/system */
private String scene;
/** 级别:info/warning/error */
private String level;
private String title;
private String content;
/** 是否已读(前端展示用,等价于 readAt 非空) */
private Boolean read;
private LocalDateTime readAt;
private LocalDateTime createdAt;
}
@@ -0,0 +1,16 @@
package com.nanri.aiimage.modules.notification.model.vo;
import lombok.Data;
import java.util.List;
/** 通知分页结果(含未读总数,供铃铛徽标刷新)。 */
@Data
public class NotificationPageVo {
private List<NotificationItemVo> items;
private Long total;
private Long page;
private Long pageSize;
private Long unreadCount;
}
@@ -0,0 +1,11 @@
package com.nanri.aiimage.modules.notification.model.vo;
import lombok.Data;
/** 通知摘要:未读数 + 最新通知 id(前端轮询用,发现 latestId 增大即弹提醒)。 */
@Data
public class NotificationSummaryVo {
private Long unreadCount;
private Long latestId;
}
@@ -0,0 +1,127 @@
package com.nanri.aiimage.modules.notification.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
import com.nanri.aiimage.modules.permission.support.UserDataScopeSupport;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 通知分发:按受众把通知落到具体接收者。
* - 桌面端用户:直接推送该用户(audience=user);
* - 后台管理员:全部超管 + 管理员,subjectUserId 非空时按数据权限过滤
* (超管全量;管理员仅看自己管辖分组内用户的事件,与后台密钥管理页一致)。
* 批量场景(任务失败扫描)先用 {@link #prepareAdminAudience()} 解析一次受众,循环内复用避免重复查库。
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class NotificationDispatchService {
private static final int ADMIN_LIMIT = 500;
private final NotificationService notificationService;
private final AdminUserMapper adminUserMapper;
private final AdminAuthSupport adminAuthSupport;
private final UserDataScopeSupport userDataScopeSupport;
/** 推给某个桌面端用户。 */
public boolean pushToUser(Long userId, String scene, String level, String title, String content, String dedupeKey) {
return notificationService.pushOrRefresh(userId, NotificationService.AUDIENCE_USER, scene, level, title, content, dedupeKey);
}
/** 单次推送管理员(内部解析一次受众;扫描 job 批量场景请用 prepareAdminAudience 复用)。 */
public int pushToAdmins(String scene, String level, String title, String content,
String dedupeKeyBase, Long subjectUserId) {
return pushToAdmins(prepareAdminAudience(), scene, level, title, content, dedupeKeyBase, subjectUserId);
}
/**
* 推送管理员(复用已解析受众)。
*
* @param subjectUserId 事件主体用户;非空时按数据权限过滤,null 表示全局事件(如服务异常)所有管理员可见
* @return 实际落库条数
*/
public int pushToAdmins(AdminAudience audience, String scene, String level, String title, String content,
String dedupeKeyBase, Long subjectUserId) {
int pushed = 0;
int filtered = 0;
for (AdminUserEntity admin : audience.admins()) {
if (!audience.canReceive(admin.getId(), subjectUserId)) {
filtered++;
continue;
}
String key = dedupeKeyBase + ":" + admin.getId();
if (notificationService.pushOrRefresh(admin.getId(), NotificationService.AUDIENCE_ADMIN,
scene, level, title, content, key)) {
pushed++;
}
}
log.info("[notification] 管理员分发完成 scene={} subjectUserId={} 受众={} 权限过滤={} 落库={}",
scene, subjectUserId, audience.admins().size(), filtered, pushed);
return pushed;
}
/** 解析管理员受众:全部超管 + 管理员(非管理员角色一律排除),并预取各管理员可见用户集。 */
public AdminAudience prepareAdminAudience() {
List<AdminUserEntity> candidates = adminUserMapper.selectList(new LambdaQueryWrapper<AdminUserEntity>()
.eq(AdminUserEntity::getIsAdmin, 1)
.last("limit " + ADMIN_LIMIT));
List<AdminUserEntity> admins = new ArrayList<>();
Set<Long> superAdminIds = new HashSet<>();
Map<Long, Set<Long>> visibleByAdmin = new HashMap<>();
for (AdminUserEntity candidate : candidates) {
String role = adminAuthSupport.currentRole(candidate);
if (role == null) {
continue;
}
admins.add(candidate);
if ("super_admin".equals(role)) {
superAdminIds.add(candidate.getId());
} else {
visibleByAdmin.put(candidate.getId(), new HashSet<>(userDataScopeSupport.resolveVisibleUserIds(candidate.getId())));
}
}
log.info("[notification] 管理员受众解析完成 管理员数={} 超管={} 需按分组过滤={}",
admins.size(), superAdminIds.size(), visibleByAdmin.size());
return new AdminAudience(admins, superAdminIds, visibleByAdmin);
}
/** 用户名(通知文案用):查不到时回退「用户#id」。 */
public String displayNameOf(Long userId) {
if (userId == null) {
return "未知用户";
}
AdminUserEntity user = adminUserMapper.selectById(userId);
String username = user == null ? null : user.getUsername();
if (username == null || username.isBlank()) {
return "用户#" + userId;
}
return username;
}
/** 一次解析好的管理员受众(admins + 超管集合 + 各管理员可见用户集),可跨多条事件复用。 */
public record AdminAudience(List<AdminUserEntity> admins,
Set<Long> superAdminIds,
Map<Long, Set<Long>> visibleByAdmin) {
/** 该管理员是否可接收主体用户为 subjectUserId 的事件;subjectUserId=null 为全局事件。 */
public boolean canReceive(Long adminId, Long subjectUserId) {
if (subjectUserId == null || superAdminIds.contains(adminId)) {
return true;
}
Set<Long> visible = visibleByAdmin.get(adminId);
return visible != null && visible.contains(subjectUserId);
}
}
}
@@ -0,0 +1,374 @@
package com.nanri.aiimage.modules.notification.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.nanri.aiimage.config.HttpClientPool;
import com.nanri.aiimage.config.NotificationProperties;
import com.nanri.aiimage.common.service.DistributedJobLockService;
import com.nanri.aiimage.modules.brand.mapper.BrandCrawlTaskMapper;
import com.nanri.aiimage.modules.brand.model.entity.BrandCrawlTaskEntity;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.usersecret.client.JikipProxyClient;
import com.nanri.aiimage.modules.usersecret.model.vo.UserApiSecretBalanceVo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 站内通知扫描:任务失败聚合提醒 + 下游服务健康探测。
*
* <p>任务失败:回看窗口内进入失败终态的任务(biz_file_task / brand_crawl_tasks),
* 按「用户 × 模块 × 小时」聚合,同一小时桶内增量刷新同一条通知(不刷屏);
* 用户侧推给任务创建者,管理员侧按数据权限推给管辖该用户的管理员。
*
* <p>服务探测:品牌检测服务(15126) / 跟价任务 API18960 / jikip 代理接口,
* 失败立即重试一次(过滤瞬抖),两次都失败才告警;同服务每小时最多一条。
*
* <p>双实例经 Redis 分布式锁互斥;所有分支留中文日志便于线上排查。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class NotificationScanScheduler {
private static final String LOCK_NAME = "notification-scan";
private static final String STATUS_FAILED = "FAILED";
private static final String BRAND_STATUS_FAILED = "failed";
private static final String BRAND_MODULE_TYPE = "BRAND";
private static final int MAX_TASK_NO_PREVIEW = 5;
private static final int REASON_MAX_LENGTH = 120;
private static final int PROBE_READ_TIMEOUT_MILLIS = 8_000;
private static final DateTimeFormatter HOUR_FORMAT = DateTimeFormatter.ofPattern("yyyyMMddHH");
/** 任务模块类型 → 中文名(与 TaskHeartbeatService 的模块常量对齐)。 */
private static final Map<String, String> MODULE_LABELS = Map.ofEntries(
Map.entry("APPEARANCE_PATENT", "外观专利检测"),
Map.entry("SIMILAR_ASIN", "货源查询"),
Map.entry("PATROL_DELETE", "巡店删除"),
Map.entry("PRICE_TRACK", "跟价"),
Map.entry("PRODUCT_RISK_RESOLVE", "商品风险解决"),
Map.entry("QUERY_ASIN", "查询ASIN"),
Map.entry("WITHDRAW", "取款"),
Map.entry("SHOP_DATA_CRAWL", "店铺数据抓取"),
Map.entry("SHOP_MATCH", "定时匹配"),
Map.entry("BRAND", "品牌检测"),
Map.entry("COLLECT_DATA", "采集数据"),
Map.entry("DELETE_BRAND", "删除ASIN"),
Map.entry("SPLIT", "数据拆分"),
Map.entry("CONVERT", "格式转换"),
Map.entry("PUBLISH", "上架"),
Map.entry("DEDUPE", "数据去重"));
private final FileTaskMapper fileTaskMapper;
private final BrandCrawlTaskMapper brandCrawlTaskMapper;
private final NotificationService notificationService;
private final NotificationDispatchService notificationDispatchService;
private final DistributedJobLockService distributedJobLockService;
private final NotificationProperties properties;
private final JikipProxyClient jikipProxyClient;
/** 上次清理日期(每天最多清理一次;双节点由分布式锁保证只有一个实例执行)。 */
private volatile LocalDate lastCleanupDate;
@Scheduled(fixedDelayString = "${aiimage.notification.scan-interval-ms:300000}",
initialDelayString = "${aiimage.notification.scan-initial-delay-ms:120000}")
public void scan() {
if (!properties.isScanEnabled()) {
log.info("[notification-scan] 扫描已关闭(scan-enabled=false),跳过本轮");
return;
}
var lock = distributedJobLockService.tryLock(LOCK_NAME, Duration.ofMinutes(5));
if (lock == null) {
log.info("[notification-scan] 另一实例持有扫描锁,跳过本轮");
return;
}
try (lock) {
if (properties.isTaskScanEnabled()) {
scanFailedTasks();
}
if (properties.isServiceProbeEnabled()) {
probeServices();
}
cleanupExpiredIfNeeded();
} catch (Exception ex) {
log.warn("[notification-scan] 扫描异常终止 err={}", ex.getMessage(), ex);
}
}
/** 任务失败聚合扫描:窗口内失败终态任务按「用户 × 模块 × 小时」聚合成一条通知。 */
void scanFailedTasks() {
LocalDateTime cutoff = LocalDateTime.now()
.minusMinutes(Math.max(1, properties.getTaskFailedWindowMinutes()));
int limit = Math.max(1, properties.getTaskScanMaxRows());
List<FailedTask> failed = new ArrayList<>();
List<FileTaskEntity> fileTasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
.select(FileTaskEntity::getId, FileTaskEntity::getTaskNo, FileTaskEntity::getModuleType,
FileTaskEntity::getUserId, FileTaskEntity::getErrorMessage, FileTaskEntity::getUpdatedAt)
.eq(FileTaskEntity::getStatus, STATUS_FAILED)
.gt(FileTaskEntity::getUpdatedAt, cutoff)
.orderByDesc(FileTaskEntity::getId)
.last("limit " + limit));
for (FileTaskEntity task : fileTasks) {
if (task.getUserId() == null || task.getUserId() <= 0) {
continue;
}
failed.add(new FailedTask(task.getId(), displayTaskNo(task.getTaskNo(), task.getId()),
task.getModuleType(), task.getUserId(), task.getErrorMessage()));
}
List<BrandCrawlTaskEntity> brandTasks = brandCrawlTaskMapper.selectList(new LambdaQueryWrapper<BrandCrawlTaskEntity>()
.select(BrandCrawlTaskEntity::getId, BrandCrawlTaskEntity::getUserId,
BrandCrawlTaskEntity::getErrorMessage, BrandCrawlTaskEntity::getUpdatedAt)
.eq(BrandCrawlTaskEntity::getStatus, BRAND_STATUS_FAILED)
.gt(BrandCrawlTaskEntity::getUpdatedAt, cutoff)
.orderByDesc(BrandCrawlTaskEntity::getId)
.last("limit " + limit));
for (BrandCrawlTaskEntity task : brandTasks) {
if (task.getUserId() == null || task.getUserId() <= 0) {
continue;
}
failed.add(new FailedTask(task.getId(), "#" + task.getId(),
BRAND_MODULE_TYPE, task.getUserId(), task.getErrorMessage()));
}
if (failed.isEmpty()) {
log.info("[notification-scan] 近 {} 分钟无失败任务", properties.getTaskFailedWindowMinutes());
return;
}
String hour = LocalDateTime.now().format(HOUR_FORMAT);
Map<BucketKey, List<FailedTask>> buckets = new LinkedHashMap<>();
for (FailedTask task : failed) {
String moduleType = normalizeModuleType(task.moduleType());
buckets.computeIfAbsent(new BucketKey(task.userId(), moduleType, hour), key -> new ArrayList<>())
.add(task);
}
NotificationDispatchService.AdminAudience audience = notificationDispatchService.prepareAdminAudience();
int userPushed = 0;
int adminPushed = 0;
for (Map.Entry<BucketKey, List<FailedTask>> entry : buckets.entrySet()) {
BucketKey key = entry.getKey();
List<FailedTask> tasks = entry.getValue();
String label = MODULE_LABELS.getOrDefault(key.moduleType(), key.moduleType());
String preview = taskNoPreview(tasks);
String reason = latestReason(tasks);
String userContent = "最近 " + properties.getTaskFailedWindowMinutes() + " 分钟内有 " + tasks.size()
+ "" + label + "任务失败"
+ (preview.isEmpty() ? "" : "" + preview + "")
+ (reason.isEmpty() ? "" : ";最近失败原因:" + reason)
+ ",详情请查看对应工具页的「历史任务」。";
if (notificationService.pushOrRefresh(key.userId(), NotificationService.AUDIENCE_USER,
NotificationService.SCENE_TASK_FAILED, NotificationService.LEVEL_WARNING,
label + "任务失败", userContent, dedupeKeyOf(key))) {
userPushed++;
}
String username = notificationDispatchService.displayNameOf(key.userId());
String adminContent = "用户 " + username + "uid=" + key.userId() + ")有 " + tasks.size()
+ "" + label + "任务失败"
+ (preview.isEmpty() ? "" : "" + preview + "")
+ (reason.isEmpty() ? "" : ";最近失败原因:" + reason);
adminPushed += notificationDispatchService.pushToAdmins(audience,
NotificationService.SCENE_TASK_FAILED, NotificationService.LEVEL_WARNING,
"用户任务失败:" + username, adminContent,
"task_failed_admin:" + key.userId() + ":" + key.moduleType() + ":" + key.hour(),
key.userId());
}
log.info("[notification-scan] 任务失败扫描完成 窗口={}分钟 失败任务={} 聚合桶={} 用户通知={} 管理员通知={}",
properties.getTaskFailedWindowMinutes(), failed.size(), buckets.size(), userPushed, adminPushed);
}
/** 下游服务健康探测:品牌检测服务 / 跟价任务 API / jikip 代理接口。 */
void probeServices() {
if (hasText(properties.getBrandServiceUrl())) {
reportProbe("brand-service", "品牌检测服务",
probeHttpWithRetry(joinUrl(properties.getBrandServiceUrl(), "/api/version")));
} else {
log.info("[notification-scan] 品牌检测服务探测地址未配置,跳过");
}
if (hasText(properties.getPriceTrackApiUrl())) {
reportProbe("price-track-api", "跟价任务 API",
probeHttpWithRetry(joinUrl(properties.getPriceTrackApiUrl(), "/")));
} else {
log.info("[notification-scan] 跟价任务 API 探测地址未配置,跳过");
}
if (properties.isJikipProbeEnabled()) {
reportProbe("jikip", "jikip 代理接口", probeJikip());
}
}
/** HTTP 探测:失败立即重试一次,两次都失败才算故障(过滤瞬时抖动)。 */
private ProbeOutcome probeHttpWithRetry(String url) {
ProbeOutcome first = probeHttpOnce(url);
if (first.ok()) {
return first;
}
log.warn("[notification-scan] 服务探测第一次失败,重试一次 url={} detail={}", url, first.detail());
return probeHttpOnce(url);
}
private ProbeOutcome probeHttpOnce(String url) {
long startMillis = System.currentTimeMillis();
try {
Integer status = RestClient.builder()
.requestFactory(HttpClientPool.requestFactory(PROBE_READ_TIMEOUT_MILLIS))
.build()
.get()
.uri(url)
.exchange((request, response) -> response.getStatusCode().value());
long latency = System.currentTimeMillis() - startMillis;
// 任意 HTTP < 500 视为存活(4xx 说明服务在线,仅路径/鉴权问题)
if (status != null && status < 500) {
return new ProbeOutcome(true, "HTTP " + status + "" + latency + "ms");
}
return new ProbeOutcome(false, "HTTP " + status + "" + latency + "ms");
} catch (Exception ex) {
long latency = System.currentTimeMillis() - startMillis;
return new ProbeOutcome(false, rootCauseMessage(ex) + "" + latency + "ms");
}
}
/** jikip 探测:复用余额查询接口;未配置套餐信息时跳过(返回 null 不告警)。 */
private ProbeOutcome probeJikip() {
try {
UserApiSecretBalanceVo balance = jikipProxyClient.fetchBalance();
if (Boolean.TRUE.equals(balance.getAvailable())) {
return new ProbeOutcome(true, "余额接口可用 surplus=" + balance.getSurplus()
+ " balance=" + balance.getBalance());
}
String message = balance.getMessage() == null ? "" : balance.getMessage();
if (message.contains("未配置")) {
log.info("[notification-scan] jikip 探测跳过:{}", message);
return null;
}
return new ProbeOutcome(false, message.isEmpty() ? "余额接口不可用" : message);
} catch (Exception ex) {
return new ProbeOutcome(false, rootCauseMessage(ex));
}
}
/** 探测结果上报:正常记 info;故障按小时去重推管理员通知;outcome=null 表示跳过。 */
private void reportProbe(String serviceKey, String serviceLabel, ProbeOutcome outcome) {
if (outcome == null) {
return;
}
if (outcome.ok()) {
log.info("[notification-scan] 服务探测正常 service={} detail={}", serviceLabel, outcome.detail());
return;
}
log.warn("[notification-scan] 服务探测失败 service={} detail={}", serviceLabel, outcome.detail());
String hour = LocalDateTime.now().format(HOUR_FORMAT);
notificationDispatchService.pushToAdmins(NotificationService.SCENE_SERVICE_DOWN,
NotificationService.LEVEL_ERROR,
serviceLabel + "不可用",
serviceLabel + "探测失败:" + outcome.detail() + "。该服务相关任务可能大面积失败,请尽快排查。",
"service_down:" + serviceKey + ":" + hour, null);
}
/** 已读通知保留期清理:每天最多一次。 */
private void cleanupExpiredIfNeeded() {
LocalDate today = LocalDate.now();
if (today.equals(lastCleanupDate)) {
return;
}
lastCleanupDate = today;
LocalDateTime cutoff = LocalDateTime.now().minusDays(Math.max(1, properties.getReadRetentionDays()));
notificationService.cleanupReadBefore(cutoff);
}
private String dedupeKeyOf(BucketKey key) {
return "task_failed:" + key.userId() + ":" + key.moduleType() + ":" + key.hour();
}
private String taskNoPreview(List<FailedTask> tasks) {
List<String> previews = new ArrayList<>(MAX_TASK_NO_PREVIEW);
for (FailedTask task : tasks) {
if (task.taskNo() == null || task.taskNo().isBlank()) {
continue;
}
previews.add(task.taskNo());
if (previews.size() >= MAX_TASK_NO_PREVIEW) {
break;
}
}
if (previews.isEmpty()) {
return "";
}
String text = String.join("", previews);
return tasks.size() > previews.size() ? text + "" : text;
}
private String latestReason(List<FailedTask> tasks) {
for (FailedTask task : tasks) {
if (task.errorMessage() != null && !task.errorMessage().isBlank()) {
String reason = task.errorMessage().trim().replaceAll("\\s+", " ");
return reason.length() <= REASON_MAX_LENGTH ? reason : reason.substring(0, REASON_MAX_LENGTH) + "";
}
}
return "";
}
private String displayTaskNo(String taskNo, Long id) {
return taskNo == null || taskNo.isBlank() ? "#" + id : taskNo;
}
private String normalizeModuleType(String moduleType) {
String normalized = moduleType == null ? "" : moduleType.trim();
if (normalized.isEmpty()) {
return "UNKNOWN";
}
// 历史小写值(legacy collectdata)统一成大写常量
return normalized.equalsIgnoreCase("collectdata") ? "COLLECT_DATA" : normalized.toUpperCase();
}
private boolean hasText(String value) {
return value != null && !value.trim().isEmpty();
}
private String joinUrl(String baseUrl, String path) {
String base = baseUrl == null ? "" : baseUrl.trim();
if (base.endsWith("/")) {
base = base.substring(0, base.length() - 1);
}
return base + path;
}
private String rootCauseMessage(Throwable throwable) {
Throwable current = throwable;
while (current.getCause() != null && current.getCause() != current) {
current = current.getCause();
}
String message = current.getMessage();
if (message == null || message.isBlank()) {
return current.getClass().getSimpleName();
}
return current.getClass().getSimpleName() + ": " + message;
}
/** 聚合桶键:用户 × 模块 × 小时。 */
record BucketKey(Long userId, String moduleType, String hour) {
}
/** 统一视图的失败任务(两类表归一)。 */
record FailedTask(Long id, String taskNo, String moduleType, Long userId, String errorMessage) {
}
/** 探测结果。 */
record ProbeOutcome(boolean ok, String detail) {
}
}
@@ -0,0 +1,252 @@
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.entity.UserNotificationEntity;
import com.nanri.aiimage.modules.notification.model.vo.NotificationItemVo;
import com.nanri.aiimage.modules.notification.model.vo.NotificationPageVo;
import com.nanri.aiimage.modules.notification.model.vo.NotificationSummaryVo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 站内通知存取:桌面端用户(audience=user)与后台管理员(audience=admin)共用一张表,
* 按 user_id + audience 隔离;push 前按 dedupe_key 精确查重(键自带时间粒度,见各来源生成规则)。
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class NotificationService {
public static final String AUDIENCE_USER = "user";
public static final String AUDIENCE_ADMIN = "admin";
public static final String LEVEL_INFO = "info";
public static final String LEVEL_WARNING = "warning";
public static final String LEVEL_ERROR = "error";
public static final String SCENE_SECRET_BALANCE = "secret_balance";
public static final String SCENE_SECRET_INVALID = "secret_invalid";
public static final String SCENE_TASK_FAILED = "task_failed";
public static final String SCENE_SERVICE_DOWN = "service_down";
public static final String SCENE_SYSTEM = "system";
private static final long MAX_PAGE_SIZE = 100L;
private static final int TITLE_MAX_LENGTH = 128;
private static final int CONTENT_MAX_LENGTH = 512;
private static final int DEDUPE_KEY_MAX_LENGTH = 160;
private final UserNotificationMapper userNotificationMapper;
/**
* 推送一条通知。dedupeKey 非空且库中已存在同键记录时跳过(返回 false),
* 用于「同一异常在时间窗内只提醒一次」;dedupeKey 为空则不去重。
*/
public boolean push(Long userId, String audience, String scene, String level,
String title, String content, String dedupeKey) {
if (userId == null || userId <= 0) {
log.warn("[notification] 推送被跳过:接收者无效 userId={} scene={}", userId, scene);
return false;
}
String key = normalize(dedupeKey);
if (!key.isEmpty() && existsByDedupeKey(key)) {
log.info("[notification] 去重命中,跳过推送 userId={} audience={} scene={} dedupeKey={}",
userId, audience, scene, key);
return false;
}
UserNotificationEntity row = new UserNotificationEntity();
row.setUserId(userId);
row.setAudience(normalize(audience).isEmpty() ? AUDIENCE_USER : audience);
row.setScene(normalize(scene).isEmpty() ? SCENE_SYSTEM : scene);
row.setLevel(normalize(level).isEmpty() ? LEVEL_WARNING : level);
row.setTitle(truncate(title, TITLE_MAX_LENGTH));
row.setContent(truncate(content == null ? "" : content, CONTENT_MAX_LENGTH));
row.setDedupeKey(truncate(key, DEDUPE_KEY_MAX_LENGTH));
row.setCreatedAt(LocalDateTime.now());
try {
userNotificationMapper.insert(row);
} catch (Exception ex) {
// 通知失败不阻断业务主流程(巡检/扫描 job 的调用方也不应因此中断)
log.warn("[notification] 推送落库失败 userId={} scene={} err={}", userId, scene, ex.getMessage(), ex);
return false;
}
log.info("[notification] 已推送 id={} userId={} audience={} scene={} level={} title={}",
row.getId(), userId, row.getAudience(), row.getScene(), row.getLevel(), row.getTitle());
return true;
}
/**
* 同键推送或刷新:不存在则新建;已存在且内容有变化时更新内容并重置为未读
* (用于小时桶聚合的增量更新:同小时内新增失败任务时刷新计数与明细)。
*
* @return true=新建或内容有更新(用户有新信息),false=无变化或去重未命中变化
*/
public boolean pushOrRefresh(Long userId, String audience, String scene, String level,
String title, String content, String dedupeKey) {
String key = normalize(dedupeKey);
if (key.isEmpty()) {
return push(userId, audience, scene, level, title, content, key);
}
UserNotificationEntity existing = selectByDedupeKey(key);
if (existing == null) {
return push(userId, audience, scene, level, title, content, key);
}
String nextTitle = truncate(title, TITLE_MAX_LENGTH);
String nextContent = truncate(content == null ? "" : content, CONTENT_MAX_LENGTH);
if (nextContent.equals(existing.getContent()) && nextTitle.equals(existing.getTitle())) {
return false;
}
userNotificationMapper.update(null, new LambdaUpdateWrapper<UserNotificationEntity>()
.eq(UserNotificationEntity::getId, existing.getId())
.set(UserNotificationEntity::getTitle, nextTitle)
.set(UserNotificationEntity::getContent, nextContent)
.set(UserNotificationEntity::getLevel, normalize(level).isEmpty() ? LEVEL_WARNING : level)
.set(UserNotificationEntity::getReadAt, null));
log.info("[notification] 同键通知已刷新 id={} userId={} scene={} dedupeKey={}",
existing.getId(), userId, scene, key);
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);
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)
.orderByDesc(UserNotificationEntity::getId)
.last("limit " + offset + "," + safeSize));
NotificationPageVo vo = new NotificationPageVo();
List<NotificationItemVo> items = new ArrayList<>(rows.size());
for (UserNotificationEntity row : rows) {
items.add(toItem(row));
}
vo.setItems(items);
vo.setTotal(total);
vo.setPage(safePage);
vo.setPageSize(safeSize);
vo.setUnreadCount(unreadCount(userId, audience));
return vo;
}
/** 摘要:未读数 + 最新通知 id(前端轮询判断是否有新通知)。 */
public NotificationSummaryVo summary(Long userId, String audience) {
NotificationSummaryVo vo = new NotificationSummaryVo();
vo.setUnreadCount(unreadCount(userId, audience));
UserNotificationEntity latest = userNotificationMapper.selectOne(
new LambdaQueryWrapper<UserNotificationEntity>()
.select(UserNotificationEntity::getId)
.eq(UserNotificationEntity::getUserId, userId)
.eq(UserNotificationEntity::getAudience, audience)
.orderByDesc(UserNotificationEntity::getId)
.last("limit 1"));
vo.setLatestId(latest == null ? 0L : latest.getId());
return vo;
}
public long unreadCount(Long userId, String audience) {
Long count = userNotificationMapper.selectCount(new LambdaQueryWrapper<UserNotificationEntity>()
.eq(UserNotificationEntity::getUserId, userId)
.eq(UserNotificationEntity::getAudience, audience)
.isNull(UserNotificationEntity::getReadAt));
return count == null ? 0L : count;
}
/** 标记单条已读(仅限本人、本人接收端)。 */
public boolean markRead(Long userId, String audience, Long id) {
if (id == null || id <= 0) {
log.warn("[notification] 标记已读参数无效 userId={} id={}", userId, id);
return false;
}
int updated = userNotificationMapper.update(null, new LambdaUpdateWrapper<UserNotificationEntity>()
.eq(UserNotificationEntity::getId, id)
.eq(UserNotificationEntity::getUserId, userId)
.eq(UserNotificationEntity::getAudience, audience)
.isNull(UserNotificationEntity::getReadAt)
.set(UserNotificationEntity::getReadAt, LocalDateTime.now()));
if (updated == 0) {
log.info("[notification] 标记已读未生效(不存在/非本人/已读) userId={} audience={} id={}",
userId, audience, id);
return false;
}
log.info("[notification] 已标记已读 userId={} audience={} id={}", userId, audience, id);
return true;
}
/** 全部标记已读,返回受影响条数。 */
public int markAllRead(Long userId, String audience) {
int updated = userNotificationMapper.update(null, new LambdaUpdateWrapper<UserNotificationEntity>()
.eq(UserNotificationEntity::getUserId, userId)
.eq(UserNotificationEntity::getAudience, audience)
.isNull(UserNotificationEntity::getReadAt)
.set(UserNotificationEntity::getReadAt, LocalDateTime.now()));
if (updated > 0) {
log.info("[notification] 全部已读 userId={} audience={} 更新={} 条", userId, audience, updated);
}
return updated;
}
/** 清理指定时间之前已读的通知(保留期由调用方决定)。 */
public int cleanupReadBefore(LocalDateTime cutoff) {
int deleted = userNotificationMapper.delete(new LambdaQueryWrapper<UserNotificationEntity>()
.isNotNull(UserNotificationEntity::getReadAt)
.lt(UserNotificationEntity::getCreatedAt, cutoff));
if (deleted > 0) {
log.info("[notification] 清理历史已读通知 cutoff={} 删除={} 条", cutoff, deleted);
}
return deleted;
}
private LambdaQueryWrapper<UserNotificationEntity> baseWrapper(Long userId, String audience, boolean onlyUnread) {
LambdaQueryWrapper<UserNotificationEntity> wrapper = new LambdaQueryWrapper<UserNotificationEntity>()
.eq(UserNotificationEntity::getUserId, userId)
.eq(UserNotificationEntity::getAudience, audience);
if (onlyUnread) {
wrapper.isNull(UserNotificationEntity::getReadAt);
}
return wrapper;
}
private boolean existsByDedupeKey(String dedupeKey) {
return selectByDedupeKey(dedupeKey) != null;
}
private UserNotificationEntity selectByDedupeKey(String dedupeKey) {
return userNotificationMapper.selectOne(new LambdaQueryWrapper<UserNotificationEntity>()
.eq(UserNotificationEntity::getDedupeKey, dedupeKey)
.orderByAsc(UserNotificationEntity::getId)
.last("limit 1"));
}
private NotificationItemVo toItem(UserNotificationEntity row) {
NotificationItemVo vo = new NotificationItemVo();
vo.setId(row.getId());
vo.setScene(row.getScene());
vo.setLevel(row.getLevel());
vo.setTitle(row.getTitle());
vo.setContent(row.getContent());
vo.setRead(row.getReadAt() != null);
vo.setReadAt(row.getReadAt());
vo.setCreatedAt(row.getCreatedAt());
return vo;
}
private String normalize(String value) {
return value == null ? "" : value.trim();
}
private String truncate(String value, int maxLength) {
String normalized = value == null ? "" : value.trim();
return normalized.length() <= maxLength ? normalized : normalized.substring(0, maxLength);
}
}
@@ -0,0 +1,67 @@
package com.nanri.aiimage.modules.permission.support;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
import com.nanri.aiimage.modules.shopkey.mapper.ShopManageGroupMapper;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* 数据权限范围解析:由主管(admin)uid 得到其可见/管辖的用户 id 集合。
* 与后台密钥管理页(UserApiSecretService.adminPage)同源规则:
* 自己带的「数据权限分组」成员(组长本人 + 名下子账户),无分组记录时回退按「名下子账户」兜底。
*/
@Component
@Slf4j
@RequiredArgsConstructor
public class UserDataScopeSupport {
private static final int FALLBACK_LIMIT = 2000;
private final AdminUserMapper adminUserMapper;
private final ShopManageGroupMapper shopManageGroupMapper;
/** 主管可见用户:自己 + 自己带的分组下的子账户(名下 users.created_by_id=自己)。 */
public List<Long> resolveVisibleUserIds(Long operatorId) {
if (operatorId == null) {
return List.of();
}
List<Long> ledGroupIds = listLedGroupIds(operatorId);
Set<Long> userIds = new LinkedHashSet<>();
for (Long groupId : ledGroupIds) {
userIds.addAll(shopManageGroupMapper.selectUserIdsByGroupId(groupId));
}
// 没有分组记录的主管(历史数据)回退按「名下子账户」兜底,避免可见范围整体为空。
if (userIds.isEmpty()) {
adminUserMapper.selectList(new LambdaQueryWrapper<AdminUserEntity>()
.eq(AdminUserEntity::getCreatedById, operatorId)
.last("limit " + FALLBACK_LIMIT))
.forEach(user -> {
if (user.getId() != null) {
userIds.add(user.getId());
}
});
}
userIds.add(operatorId);
return new ArrayList<>(userIds);
}
/** 主管带的分组 IDcreated_by_id / user_id = 自己)。 */
public List<Long> listLedGroupIds(Long operatorId) {
if (operatorId == null) {
return List.of();
}
return shopManageGroupMapper.selectLedGroups(operatorId).stream()
.map(ShopManageGroupEntity::getId)
.filter(id -> id != null && id > 0)
.toList();
}
}
@@ -69,8 +69,9 @@ public class UserApiSecretService {
private static final String STATUS_UNKNOWN = "unknown";
private static final int MASK_MIN_LENGTH = 8;
private static final int MESSAGE_MAX_LENGTH = 500;
/** 代理值支持两种形态:静态代理地址 http://[user:pass@]host:port,或供应商代理提取链接(无显式端口)。 */
private static final String PROXY_FORMAT_HINT =
"代理地址格式不正确,应形如 http://host:port http://user:pass@host:port";
"代理地址格式不正确,请填写 http://host:port 形式的代理地址,或代理服务商的提取链接(https://...";
private final UserApiSecretMapper userApiSecretMapper;
private final ShopCredentialCryptoService cryptoService;
@@ -786,13 +787,17 @@ public class UserApiSecretService {
}
}
/** 代理地址校验:http(s):// 开头且含 host:port(账密可省略)。 */
/**
* 代理地址校验:http(s):// 开头且 host 非空即可,兼容两种形态——
* 静态代理 http://[user:pass@]host:port,与供应商提取链接 http(s)://host/path?query(无显式端口)。
* 2026-09-13 修复:旧实现要求必须有显式端口,jikip 提取链接(默认 443)被误判为格式错误,用户无法保存。
*/
private void validateProxyValue(String value) {
try {
URI uri = URI.create(value);
boolean schemeOk = uri.getScheme() != null
&& (uri.getScheme().equalsIgnoreCase("http") || uri.getScheme().equalsIgnoreCase("https"));
if (!schemeOk || uri.getHost() == null || uri.getHost().isBlank() || uri.getPort() <= 0) {
if (!schemeOk || uri.getHost() == null || uri.getHost().isBlank()) {
throw new BusinessException(PROXY_FORMAT_HINT);
}
} catch (BusinessException ex) {
@@ -318,6 +318,22 @@ aiimage:
jikip-balance-url: ${AIIMAGE_USER_SECRET_JIKIP_BALANCE_URL:https://api.jikip.com/find-balance}
jikip-plan-id: ${AIIMAGE_USER_SECRET_JIKIP_PLAN_ID:}
jikip-user-id: ${AIIMAGE_USER_SECRET_JIKIP_USER_ID:}
# 巡检发现欠费/密钥失效时是否推送站内通知(铃铛)
notify-enabled: ${AIIMAGE_USER_SECRET_NOTIFY_ENABLED:true}
# 站内通知(铃铛):任务失败扫描 + 下游服务健康探测
notification:
scan-enabled: ${AIIMAGE_NOTIFICATION_SCAN_ENABLED:true}
scan-interval-ms: ${AIIMAGE_NOTIFICATION_SCAN_INTERVAL_MS:300000}
scan-initial-delay-ms: ${AIIMAGE_NOTIFICATION_SCAN_INITIAL_DELAY_MS:120000}
task-scan-enabled: ${AIIMAGE_NOTIFICATION_TASK_SCAN_ENABLED:true}
task-failed-window-minutes: ${AIIMAGE_NOTIFICATION_TASK_FAILED_WINDOW_MINUTES:60}
task-scan-max-rows: ${AIIMAGE_NOTIFICATION_TASK_SCAN_MAX_ROWS:1000}
service-probe-enabled: ${AIIMAGE_NOTIFICATION_SERVICE_PROBE_ENABLED:true}
# 探测地址留空=跳过该项;部署时按主机拓扑配置(15126 品牌检测 / 18960 跟价任务 API
brand-service-url: ${AIIMAGE_NOTIFICATION_BRAND_SERVICE_URL:}
price-track-api-url: ${AIIMAGE_NOTIFICATION_PRICE_TRACK_API_URL:}
jikip-probe-enabled: ${AIIMAGE_NOTIFICATION_JIKIP_PROBE_ENABLED:true}
read-retention-days: ${AIIMAGE_NOTIFICATION_READ_RETENTION_DAYS:90}
security:
shop-credential-key: ${AIIMAGE_SHOP_CREDENTIAL_KEY:change-me-shop-credential-key}
internal-token: ${AIIMAGE_INTERNAL_TOKEN:}
@@ -328,6 +344,7 @@ aiimage:
cookie-name: ${AIIMAGE_AUTH_COOKIE_NAME:aiimage_token}
cookie-secure: ${AIIMAGE_AUTH_COOKIE_SECURE:false}
cookie-same-site: ${AIIMAGE_AUTH_COOKIE_SAME_SITE:Lax}
single-device-enabled: ${AIIMAGE_AUTH_SINGLE_DEVICE_ENABLED:true}
ziniao:
enabled: ${AIIMAGE_ZINIAO_ENABLED:false}
base-url: ${AIIMAGE_ZINIAO_BASE_URL:https://sbappstoreapi.ziniao.com/openapi-router}
@@ -0,0 +1,21 @@
-- V116: 站内通知(铃铛)
-- 桌面端用户与后台管理员共用的通知表,按 audience 区分接收端;
-- 通知来源:密钥巡检(欠费/失效)、任务失败扫描、下游服务健康探测。
-- dedupe_key 自带时间粒度(当天/当小时/任务号),插入前精确查重实现去重。
CREATE TABLE IF NOT EXISTS `biz_user_notification` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` BIGINT NOT NULL COMMENT '接收者IDusers.id',
`audience` VARCHAR(16) NOT NULL DEFAULT 'user' COMMENT '接收端:user=桌面端/admin=后台',
`scene` VARCHAR(32) NOT NULL DEFAULT 'system' COMMENT '场景:secret_balance/secret_invalid/task_failed/service_down/system',
`level` VARCHAR(16) NOT NULL DEFAULT 'warning' COMMENT '级别:info/warning/error',
`title` VARCHAR(128) NOT NULL COMMENT '通知标题',
`content` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '通知内容',
`dedupe_key` VARCHAR(160) NOT NULL DEFAULT '' COMMENT '去重键(自带时间粒度,插入前精确查重)',
`read_at` DATETIME NULL COMMENT '已读时间(NULL=未读)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_user_audience_read` (`user_id`, `audience`, `read_at`, `id`),
KEY `idx_dedupe` (`dedupe_key`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='站内通知(桌面端用户 + 后台管理员)';
@@ -0,0 +1,9 @@
-- V118: 清空 users.machine,为单设备登录(互踢)做干净起点
-- 背景:users.machine 是早期「设备绑定」遗留列,绑定逻辑停用后一直没维护,
-- 存量值与用户当前设备普遍不一致。单设备登录上线后校验 machine 与
-- token 内签名 deviceId 是否一致,若不清空,历史残留会导致用户部署后
-- 立刻被判「已在其他设备登录」而下线(提示还是错的)。
-- 清空后:已登录用户不受影响,每人下一次登录时写入当前设备,自然进入新规则。
-- 幂等:只清非空值,重复执行无副作用。
UPDATE `users` SET `machine` = NULL WHERE `machine` IS NOT NULL;
@@ -1,16 +1,23 @@
package com.nanri.aiimage.modules.admin.support;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.auth.config.AuthProperties;
import com.nanri.aiimage.modules.auth.service.JwtService;
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
import io.jsonwebtoken.Claims;
import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.catchThrowableOfType;
import static org.mockito.ArgumentMatchers.any;
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 AdminAuthSupportTest {
@@ -41,12 +48,81 @@ class AdminAuthSupportTest {
assertThat(support.currentRole(user(1L, "normal", 1, null))).isNull();
}
// ---------- 单设备登录:requireUser 统一拦截 ----------
@Test
void requireUserRejectsTokenFromSupersededDevice() {
// 账号已绑定 devAtoken 内签名的设备是 devB(被新设备顶下线)→ 4011
AdminAuthSupport support = support(user(7L, "normal", 0, null, "devA"), "devB", true);
BusinessException ex = catchThrowableOfType(
() -> support.requireUser(requestWithBearer("t")), BusinessException.class);
assertThat(ex.getCode()).isEqualTo(4011);
assertThat(ex.getMessage()).contains("已在其他设备登录");
}
@Test
void requireUserPassesWhenDeviceMatches() {
AdminAuthSupport support = support(user(7L, "normal", 0, null, "devA"), "devA", true);
assertThatCode(() -> support.requireUser(requestWithBearer("t"))).doesNotThrowAnyException();
}
@Test
void requireUserExemptsSuperAdmin() {
AdminAuthSupport support = support(user(7L, "super_admin", 1, null, "devA"), "devB", true);
assertThatCode(() -> support.requireUser(requestWithBearer("t"))).doesNotThrowAnyException();
}
@Test
void requireUserPassesWhenSingleDeviceDisabled() {
AdminAuthSupport support = support(user(7L, "normal", 0, null, "devA"), "devB", false);
assertThatCode(() -> support.requireUser(requestWithBearer("t"))).doesNotThrowAnyException();
}
@Test
void requireUserPassesWhenUnbound() {
AdminAuthSupport support = support(user(7L, "normal", 0, null, null), "devB", true);
assertThatCode(() -> support.requireUser(requestWithBearer("t"))).doesNotThrowAnyException();
}
private AdminAuthSupport support(AdminUserEntity user, String claimDeviceId, boolean singleDeviceEnabled) {
JwtService jwtService = mock(JwtService.class);
AdminUserMapper userMapper = mock(AdminUserMapper.class);
AuthProperties props = mock(AuthProperties.class);
Claims claims = mock(Claims.class);
when(claims.getSubject()).thenReturn(String.valueOf(user.getId()));
when(claims.get("deviceId")).thenReturn(claimDeviceId);
when(jwtService.parse("t")).thenReturn(claims);
when(userMapper.selectById(user.getId())).thenReturn(user);
when(props.isSingleDeviceEnabled()).thenReturn(singleDeviceEnabled);
return new AdminAuthSupport(jwtService, userMapper, props);
}
private HttpServletRequest requestWithBearer(String token) {
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getHeader(HttpHeaders.AUTHORIZATION)).thenReturn("Bearer " + token);
return request;
}
private AdminUserEntity user(Long id, String role, int isAdmin, Long createdById) {
return user(id, role, isAdmin, createdById, null);
}
private AdminUserEntity user(Long id, String role, int isAdmin, Long createdById, String machine) {
AdminUserEntity user = new AdminUserEntity();
user.setId(id);
user.setUsername("u" + id);
user.setRole(role);
user.setIsAdmin(isAdmin);
user.setCreatedById(createdById);
user.setMachine(machine);
return user;
}
}
@@ -0,0 +1,184 @@
package com.nanri.aiimage.modules.auth.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.auth.config.AuthProperties;
import com.nanri.aiimage.modules.auth.mapper.LoginUserMapper;
import com.nanri.aiimage.modules.auth.model.dto.LoginRequest;
import com.nanri.aiimage.modules.auth.model.entity.LoginUserEntity;
import com.nanri.aiimage.modules.auth.model.vo.LoginResultVo;
import com.nanri.aiimage.modules.auth.util.WerkzeugPasswordEncoder;
import com.nanri.aiimage.modules.permission.service.PermissionMenuService;
import io.jsonwebtoken.Claims;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.catchThrowableOfType;
import static org.mockito.ArgumentMatchers.any;
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.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class AuthServiceTest {
@BeforeEach
void setUp() {
// lambda 列名解析需要 MyBatis-Plus TableInfo 缓存;mock 环境手动初始化。
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
LoginUserEntity.class);
}
// ---------- login:绑定当前设备 ----------
@Test
void loginBindsAccountToCurrentDevice() {
Fixture f = fixture(true);
when(f.loginUserMapper.selectOne(any())).thenReturn(f.user("devA"));
when(f.passwordEncoder.matches("pwd", "hash")).thenReturn(true);
when(f.jwtService.issue(eq(7L), anyString(), anyString())).thenReturn("jwt-token");
LoginRequest request = new LoginRequest();
request.setUsername("u7");
request.setPassword("pwd");
request.setDeviceId("devB");
LoginResultVo vo = f.service.login(request);
// 绑定写入当前设备(last-login-wins),签发的 token 也用当前设备
verify(f.loginUserMapper, times(1)).update(any(), any());
verify(f.jwtService).issue(7L, "u7", "devB");
assertThat(vo.getDeviceId()).isEqualTo("devB");
}
@Test
void loginSkipsBindingWhenSingleDeviceDisabled() {
Fixture f = fixture(false);
when(f.loginUserMapper.selectOne(any())).thenReturn(f.user("devA"));
when(f.passwordEncoder.matches("pwd", "hash")).thenReturn(true);
when(f.jwtService.issue(eq(7L), anyString(), anyString())).thenReturn("jwt-token");
LoginRequest request = new LoginRequest();
request.setUsername("u7");
request.setPassword("pwd");
request.setDeviceId("devB");
f.service.login(request);
verify(f.loginUserMapper, never()).update(any(), any());
}
// ---------- check_login:被顶下线必须拦截且不续期 ----------
@Test
void checkLoginRejectsSupersededDeviceWithoutRenewal() {
Fixture f = fixture(true);
Claims claims = f.claims("devB");
when(f.jwtService.parse("t")).thenReturn(claims);
when(f.loginUserMapper.selectById(7L)).thenReturn(f.user("devA"));
BusinessException ex = catchThrowableOfType(() -> f.service.checkLogin("t", null), BusinessException.class);
assertThat(ex.getCode()).isEqualTo(4011);
assertThat(ex.getMessage()).contains("已在其他设备登录");
// 关键:被踢的旧 token 不能在这里换到新 token「复活」
verify(f.jwtService, never()).issue(any(), anyString(), anyString());
}
@Test
void checkLoginPassesAndRenewsWithClaimDevice() {
Fixture f = fixture(true);
Claims claims = f.claims("devA");
when(f.jwtService.parse("t")).thenReturn(claims);
when(f.loginUserMapper.selectById(7L)).thenReturn(f.user("devA"));
when(f.jwtService.issue(eq(7L), anyString(), anyString())).thenReturn("jwt-token");
LoginResultVo vo = f.service.checkLogin("t", null);
verify(f.jwtService).issue(7L, "u7", "devA");
assertThat(vo.getDeviceId()).isEqualTo("devA");
}
@Test
void checkLoginIgnoresSpoofedHeaderDevice() {
// 校验以 token 内签名的 deviceId 为准:请求头塞别的设备号不影响判定,也不能签进新 token
Fixture f = fixture(true);
Claims claims = f.claims("devB");
when(f.jwtService.parse("t")).thenReturn(claims);
when(f.loginUserMapper.selectById(7L)).thenReturn(f.user("devA"));
BusinessException ex = catchThrowableOfType(
() -> f.service.checkLogin("t", "devA"), BusinessException.class);
assertThat(ex.getCode()).isEqualTo(4011);
}
@Test
void checkLoginExemptsSuperAdmin() {
Fixture f = fixture(true);
Claims claims = f.claims("devB");
when(f.jwtService.parse("t")).thenReturn(claims);
LoginUserEntity root = f.user("devA");
root.setRole("super_admin");
root.setIsAdmin(1);
when(f.loginUserMapper.selectById(7L)).thenReturn(root);
when(f.jwtService.issue(eq(7L), anyString(), anyString())).thenReturn("jwt-token");
assertThatCode(() -> f.service.checkLogin("t", null)).doesNotThrowAnyException();
}
@Test
void checkLoginPassesWhenSingleDeviceDisabled() {
Fixture f = fixture(false);
Claims claims = f.claims("devB");
when(f.jwtService.parse("t")).thenReturn(claims);
when(f.loginUserMapper.selectById(7L)).thenReturn(f.user("devA"));
when(f.jwtService.issue(eq(7L), anyString(), anyString())).thenReturn("jwt-token");
assertThatCode(() -> f.service.checkLogin("t", null)).doesNotThrowAnyException();
}
// ---------- 夹具 ----------
private Fixture fixture(boolean singleDeviceEnabled) {
LoginUserMapper loginUserMapper = mock(LoginUserMapper.class);
WerkzeugPasswordEncoder passwordEncoder = mock(WerkzeugPasswordEncoder.class);
JwtService jwtService = mock(JwtService.class);
PermissionMenuService permissionMenuService = mock(PermissionMenuService.class);
AuthProperties authProperties = mock(AuthProperties.class);
when(authProperties.isSingleDeviceEnabled()).thenReturn(singleDeviceEnabled);
when(jwtService.ttlSeconds()).thenReturn(604800L);
AuthService service = new AuthService(loginUserMapper, passwordEncoder, jwtService,
permissionMenuService, authProperties);
return new Fixture(service, loginUserMapper, passwordEncoder, jwtService);
}
private record Fixture(AuthService service, LoginUserMapper loginUserMapper,
WerkzeugPasswordEncoder passwordEncoder, JwtService jwtService) {
LoginUserEntity user(String machine) {
LoginUserEntity user = new LoginUserEntity();
user.setId(7L);
user.setUsername("u7");
user.setPasswordHash("hash");
user.setIsAdmin(0);
user.setRole("normal");
user.setMachine(machine);
return user;
}
Claims claims(String deviceId) {
Claims claims = mock(Claims.class);
when(claims.getSubject()).thenReturn("7");
when(claims.get("deviceId")).thenReturn(deviceId);
return claims;
}
}
}
@@ -0,0 +1,101 @@
package com.nanri.aiimage.modules.auth.support;
import com.nanri.aiimage.common.exception.BusinessException;
import io.jsonwebtoken.Claims;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.catchThrowableOfType;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class DeviceSessionPolicyTest {
// ---------- 角色规则(从 AdminAuthSupport.currentRole 迁移,行为必须保持一致) ----------
@Test
void explicitSuperAdminResolved() {
assertThat(DeviceSessionPolicy.resolveRole("super_admin", 1, 9L)).isEqualTo("super_admin");
}
@Test
void explicitAdminNotPromotedToSuperAdmin() {
assertThat(DeviceSessionPolicy.resolveRole("admin", 1, null)).isEqualTo("admin");
}
@Test
void legacyBlankRoleRootRemainsSuperAdmin() {
assertThat(DeviceSessionPolicy.resolveRole(null, 1, null)).isEqualTo("super_admin");
}
@Test
void legacyBlankRoleCreatedByOtherIsAdmin() {
assertThat(DeviceSessionPolicy.resolveRole("", 1, 3L)).isEqualTo("admin");
}
@Test
void normalRoleIsNotPromotedByLegacyAdminFields() {
assertThat(DeviceSessionPolicy.resolveRole("normal", 1, null)).isNull();
}
@Test
void isSuperAdminOnlyForSuperAdmin() {
assertThat(DeviceSessionPolicy.isSuperAdmin("super_admin", 1, null)).isTrue();
assertThat(DeviceSessionPolicy.isSuperAdmin("admin", 1, null)).isFalse();
assertThat(DeviceSessionPolicy.isSuperAdmin("normal", 0, null)).isFalse();
}
// ---------- 设备一致性校验 ----------
@Test
void exemptUserAlwaysPasses() {
// 超管豁免:设备不一致也放行
assertThatCode(() -> DeviceSessionPolicy.assertSameDevice("devA", "devB", true, 7L, "root"))
.doesNotThrowAnyException();
}
@Test
void unboundMachinePasses() {
// 尚未绑定(machine 为空)放行,下次登录写入后开始生效
assertThatCode(() -> DeviceSessionPolicy.assertSameDevice(null, "devB", false, 7L, "u1"))
.doesNotThrowAnyException();
assertThatCode(() -> DeviceSessionPolicy.assertSameDevice(" ", "devB", false, 7L, "u1"))
.doesNotThrowAnyException();
}
@Test
void sameDevicePasses() {
assertThatCode(() -> DeviceSessionPolicy.assertSameDevice("devA", "devA", false, 7L, "u1"))
.doesNotThrowAnyException();
}
@Test
void differentDeviceThrowsKicked() {
BusinessException ex = catchThrowableOfType(
() -> DeviceSessionPolicy.assertSameDevice("devA", "devB", false, 7L, "u1"),
BusinessException.class);
assertThat(ex.getCode()).isEqualTo(DeviceSessionPolicy.CODE_KICKED);
assertThat(ex.getMessage()).contains("已在其他设备登录");
}
@Test
void blankClaimDeviceThrowsUnauthorized() {
// 正常 token 必带 deviceId claim;缺失按登录态无效处理(普通 401,不误报"被踢"
BusinessException ex = catchThrowableOfType(
() -> DeviceSessionPolicy.assertSameDevice("devA", null, false, 7L, "u1"),
BusinessException.class);
assertThat(ex.getCode()).isEqualTo(401);
}
@Test
void claimDeviceIdExtractsAndTrims() {
assertThat(DeviceSessionPolicy.claimDeviceId(null)).isEmpty();
Claims claims = mock(Claims.class);
when(claims.get("deviceId")).thenReturn(" devA ");
assertThat(DeviceSessionPolicy.claimDeviceId(claims)).isEqualTo("devA");
}
}
@@ -0,0 +1,106 @@
package com.nanri.aiimage.modules.notification.service;
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
import com.nanri.aiimage.modules.permission.support.UserDataScopeSupport;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
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.when;
class NotificationDispatchServiceTest {
private final NotificationService notificationService = mock(NotificationService.class);
private final AdminUserMapper adminUserMapper = mock(AdminUserMapper.class);
private final AdminAuthSupport adminAuthSupport = mock(AdminAuthSupport.class);
private final UserDataScopeSupport userDataScopeSupport = mock(UserDataScopeSupport.class);
private final NotificationDispatchService service = new NotificationDispatchService(
notificationService, adminUserMapper, adminAuthSupport, userDataScopeSupport);
@Test
void prepareAudienceFiltersNonAdminAndCachesScopes() {
AdminUserEntity superAdmin = admin(1L, "超管甲");
AdminUserEntity admin = admin(2L, "主管乙");
AdminUserEntity normal = admin(3L, "员工丙");
when(adminUserMapper.selectList(any())).thenReturn(List.of(superAdmin, admin, normal));
when(adminAuthSupport.currentRole(superAdmin)).thenReturn("super_admin");
when(adminAuthSupport.currentRole(admin)).thenReturn("admin");
when(adminAuthSupport.currentRole(normal)).thenReturn(null);
when(userDataScopeSupport.resolveVisibleUserIds(2L)).thenReturn(List.of(2L, 20L, 21L));
NotificationDispatchService.AdminAudience audience = service.prepareAdminAudience();
assertThat(audience.admins()).containsExactly(superAdmin, admin);
assertThat(audience.superAdminIds()).containsExactly(1L);
assertThat(audience.visibleByAdmin()).containsOnlyKeys(2L);
// 超管全量;主管仅可见自己管辖用户;全局事件(subject=null)所有人可见
assertThat(audience.canReceive(1L, 999L)).isTrue();
assertThat(audience.canReceive(2L, 20L)).isTrue();
assertThat(audience.canReceive(2L, 999L)).isFalse();
assertThat(audience.canReceive(2L, null)).isTrue();
}
@Test
void pushToAdminsSkipsAdminOutOfDataScope() {
AdminUserEntity superAdmin = admin(1L, "超管甲");
AdminUserEntity admin = admin(2L, "主管乙");
when(adminUserMapper.selectList(any())).thenReturn(List.of(superAdmin, admin));
when(adminAuthSupport.currentRole(superAdmin)).thenReturn("super_admin");
when(adminAuthSupport.currentRole(admin)).thenReturn("admin");
when(userDataScopeSupport.resolveVisibleUserIds(2L)).thenReturn(List.of(2L));
when(notificationService.pushOrRefresh(anyLong(), anyString(), anyString(), anyString(), anyString(), anyString(), anyString()))
.thenReturn(true);
NotificationDispatchService.AdminAudience audience = service.prepareAdminAudience();
int pushed = service.pushToAdmins(audience, NotificationService.SCENE_TASK_FAILED,
NotificationService.LEVEL_WARNING, "标题", "内容", "task_failed_admin:20:PRICE_TRACK:2026091310", 20L);
// 主管乙不可见用户 20 → 只有超管收到
assertThat(pushed).isEqualTo(1);
org.mockito.Mockito.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"));
}
@Test
void pushToUserUsesUserAudience() {
when(notificationService.pushOrRefresh(anyLong(), anyString(), anyString(), anyString(), anyString(), anyString(), anyString()))
.thenReturn(true);
boolean pushed = service.pushToUser(7L, NotificationService.SCENE_SECRET_BALANCE,
NotificationService.LEVEL_ERROR, "标题", "内容", "secret_balance:7:proxy:20260913");
assertThat(pushed).isTrue();
org.mockito.Mockito.verify(notificationService).pushOrRefresh(eq(7L), eq(NotificationService.AUDIENCE_USER),
eq(NotificationService.SCENE_SECRET_BALANCE), eq(NotificationService.LEVEL_ERROR),
eq("标题"), eq("内容"), eq("secret_balance:7:proxy:20260913"));
}
@Test
void displayNameFallsBackToUidWhenMissing() {
AdminUserEntity user = admin(7L, "张三");
when(adminUserMapper.selectById(7L)).thenReturn(user);
when(adminUserMapper.selectById(8L)).thenReturn(null);
assertThat(service.displayNameOf(7L)).isEqualTo("张三");
assertThat(service.displayNameOf(8L)).isEqualTo("用户#8");
assertThat(service.displayNameOf(null)).isEqualTo("未知用户");
}
private AdminUserEntity admin(Long id, String username) {
AdminUserEntity user = new AdminUserEntity();
user.setId(id);
user.setUsername(username);
user.setIsAdmin(1);
return user;
}
}
@@ -0,0 +1,130 @@
package com.nanri.aiimage.modules.notification.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.nanri.aiimage.config.NotificationProperties;
import com.nanri.aiimage.modules.brand.mapper.BrandCrawlTaskMapper;
import com.nanri.aiimage.modules.brand.model.entity.BrandCrawlTaskEntity;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.usersecret.client.JikipProxyClient;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
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.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class NotificationScanSchedulerTest {
/** 单测无 Spring 上下文,需手动注册实体 TableInfo 供 LambdaWrapper 解析列名。 */
@BeforeAll
static void initTableInfo() {
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
TableInfoHelper.initTableInfo(assistant, BrandCrawlTaskEntity.class);
}
private final FileTaskMapper fileTaskMapper = mock(FileTaskMapper.class);
private final BrandCrawlTaskMapper brandCrawlTaskMapper = mock(BrandCrawlTaskMapper.class);
private final NotificationService notificationService = mock(NotificationService.class);
private final NotificationDispatchService dispatch = mock(NotificationDispatchService.class);
private final com.nanri.aiimage.common.service.DistributedJobLockService lockService =
mock(com.nanri.aiimage.common.service.DistributedJobLockService.class);
private final NotificationProperties properties = new NotificationProperties();
private final JikipProxyClient jikipProxyClient = mock(JikipProxyClient.class);
private NotificationScanScheduler newScheduler() {
return new NotificationScanScheduler(fileTaskMapper, brandCrawlTaskMapper, notificationService,
dispatch, lockService, properties, jikipProxyClient);
}
@Test
void scanGroupsFailedTasksByUserAndModuleAndRefreshesSameBucket() {
when(fileTaskMapper.selectList(any())).thenReturn(List.of(
fileTask(101L, "T-101", "PRICE_TRACK", 7L, "浏览器启动失败"),
fileTask(102L, "T-102", "PRICE_TRACK", 7L, "cookie 失效"),
fileTask(103L, "T-103", "SIMILAR_ASIN", 8L, null)));
BrandCrawlTaskEntity brandTask = new BrandCrawlTaskEntity();
brandTask.setId(201L);
brandTask.setUserId(7L);
brandTask.setErrorMessage("品牌检测服务不可达");
when(brandCrawlTaskMapper.selectList(any())).thenReturn(List.of(brandTask));
when(dispatch.prepareAdminAudience()).thenReturn(
new NotificationDispatchService.AdminAudience(List.of(), Set.of(), Map.of()));
when(dispatch.displayNameOf(anyLong())).thenReturn("张三");
when(notificationService.pushOrRefresh(anyLong(), anyString(), anyString(), anyString(), anyString(), anyString(), anyString()))
.thenReturn(true);
when(dispatch.pushToAdmins(any(NotificationDispatchService.AdminAudience.class), anyString(), anyString(),
anyString(), anyString(), anyString(), any())).thenReturn(1);
newScheduler().scanFailedTasks();
// 三个桶:用户7×跟价、用户7×品牌检测、用户8×货源查询
ArgumentCaptor<String> contentCaptor = ArgumentCaptor.forClass(String.class);
verify(notificationService, times(3)).pushOrRefresh(anyLong(), eq(NotificationService.AUDIENCE_USER),
eq(NotificationService.SCENE_TASK_FAILED), eq(NotificationService.LEVEL_WARNING),
anyString(), contentCaptor.capture(), anyString());
List<String> contents = contentCaptor.getAllValues();
assertThat(contents).anySatisfy(text -> {
assertThat(text).contains("2 个跟价任务失败");
assertThat(text).contains("T-101");
assertThat(text).contains("T-102");
});
assertThat(contents).anySatisfy(text -> assertThat(text).contains("1 个货源查询任务失败"));
assertThat(contents).anySatisfy(text -> assertThat(text).contains("1 个品牌检测任务失败"));
verify(dispatch, times(3)).pushToAdmins(any(NotificationDispatchService.AdminAudience.class),
eq(NotificationService.SCENE_TASK_FAILED), eq(NotificationService.LEVEL_WARNING),
anyString(), anyString(), anyString(), anyLong());
}
@Test
void scanDoesNothingWhenNoFailedTasks() {
when(fileTaskMapper.selectList(any())).thenReturn(List.of());
when(brandCrawlTaskMapper.selectList(any())).thenReturn(List.of());
newScheduler().scanFailedTasks();
verify(notificationService, org.mockito.Mockito.never()).pushOrRefresh(anyLong(), anyString(), anyString(),
anyString(), anyString(), anyString(), anyString());
verify(dispatch, org.mockito.Mockito.never()).pushToAdmins(any(), anyString(), anyString(),
anyString(), anyString(), anyString(), any());
}
@Test
void scanSkipsRowsWithoutUserId() {
when(fileTaskMapper.selectList(any())).thenReturn(List.of(
fileTask(101L, "T-101", "PRICE_TRACK", null, "无主任务")));
when(brandCrawlTaskMapper.selectList(any())).thenReturn(List.of());
newScheduler().scanFailedTasks();
verify(notificationService, org.mockito.Mockito.never()).pushOrRefresh(anyLong(), anyString(), anyString(),
anyString(), anyString(), anyString(), anyString());
}
private FileTaskEntity fileTask(Long id, String taskNo, String moduleType, Long userId, String errorMessage) {
FileTaskEntity task = new FileTaskEntity();
task.setId(id);
task.setTaskNo(taskNo);
task.setModuleType(moduleType);
task.setUserId(userId);
task.setErrorMessage(errorMessage);
task.setStatus("FAILED");
task.setUpdatedAt(LocalDateTime.now());
return task;
}
}
@@ -0,0 +1,189 @@
package com.nanri.aiimage.modules.notification.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.nanri.aiimage.modules.notification.mapper.UserNotificationMapper;
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;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class NotificationServiceTest {
/** 单测无 Spring 上下文,需手动注册实体 TableInfo 供 LambdaWrapper 解析列名。 */
@BeforeAll
static void initTableInfo() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
UserNotificationEntity.class);
}
private final UserNotificationMapper mapper = mock(UserNotificationMapper.class);
private final NotificationService service = new NotificationService(mapper);
@Test
void pushSkipsWhenDedupeKeyExists() {
UserNotificationEntity existing = new UserNotificationEntity();
existing.setId(1L);
existing.setDedupeKey("secret_balance:7:proxy:20260913");
when(mapper.selectOne(any())).thenReturn(existing);
boolean pushed = service.push(7L, NotificationService.AUDIENCE_USER,
NotificationService.SCENE_SECRET_BALANCE, NotificationService.LEVEL_ERROR,
"代理欠费", "余额不足", "secret_balance:7:proxy:20260913");
assertThat(pushed).isFalse();
verify(mapper, never()).insert(any(UserNotificationEntity.class));
}
@Test
void pushInsertsNormalizedRowWhenNoDuplicate() {
when(mapper.selectOne(any())).thenReturn(null);
when(mapper.insert(any(UserNotificationEntity.class))).thenReturn(1);
boolean pushed = service.push(7L, NotificationService.AUDIENCE_USER,
NotificationService.SCENE_SECRET_BALANCE, NotificationService.LEVEL_ERROR,
" 代理欠费 ", " 余额不足 ", "secret_balance:7:proxy:20260913");
assertThat(pushed).isTrue();
ArgumentCaptor<UserNotificationEntity> captor = ArgumentCaptor.forClass(UserNotificationEntity.class);
verify(mapper).insert(captor.capture());
UserNotificationEntity row = captor.getValue();
assertThat(row.getUserId()).isEqualTo(7L);
assertThat(row.getAudience()).isEqualTo("user");
assertThat(row.getScene()).isEqualTo("secret_balance");
assertThat(row.getLevel()).isEqualTo("error");
assertThat(row.getTitle()).isEqualTo("代理欠费");
assertThat(row.getContent()).isEqualTo("余额不足");
assertThat(row.getDedupeKey()).isEqualTo("secret_balance:7:proxy:20260913");
assertThat(row.getCreatedAt()).isNotNull();
assertThat(row.getReadAt()).isNull();
}
@Test
void pushRejectsInvalidReceiver() {
assertThat(service.push(null, "user", "system", "info", "t", "c", "")).isFalse();
assertThat(service.push(0L, "user", "system", "info", "t", "c", "")).isFalse();
verify(mapper, never()).insert(any(UserNotificationEntity.class));
}
@Test
void pushOrRefreshUpdatesContentAndResetsUnread() {
UserNotificationEntity existing = new UserNotificationEntity();
existing.setId(9L);
existing.setTitle("跟价任务失败");
existing.setContent("最近 60 分钟内有 2 个跟价任务失败");
existing.setLevel("warning");
existing.setReadAt(LocalDateTime.now());
when(mapper.selectOne(any())).thenReturn(existing);
boolean refreshed = service.pushOrRefresh(7L, NotificationService.AUDIENCE_ADMIN,
NotificationService.SCENE_TASK_FAILED, NotificationService.LEVEL_WARNING,
"跟价任务失败", "最近 60 分钟内有 5 个跟价任务失败", "task_failed:7:PRICE_TRACK:2026091310");
assertThat(refreshed).isTrue();
verify(mapper, never()).insert(any(UserNotificationEntity.class));
verify(mapper, times(1)).update(any(), any());
}
@Test
void pushOrRefreshKeepsRowWhenContentUnchanged() {
UserNotificationEntity existing = new UserNotificationEntity();
existing.setId(9L);
existing.setTitle("跟价任务失败");
existing.setContent("最近 60 分钟内有 2 个跟价任务失败");
when(mapper.selectOne(any())).thenReturn(existing);
boolean refreshed = service.pushOrRefresh(7L, NotificationService.AUDIENCE_USER,
NotificationService.SCENE_TASK_FAILED, NotificationService.LEVEL_WARNING,
"跟价任务失败", "最近 60 分钟内有 2 个跟价任务失败", "task_failed:7:PRICE_TRACK:2026091310");
assertThat(refreshed).isFalse();
verify(mapper, never()).update(any(), any());
verify(mapper, never()).insert(any(UserNotificationEntity.class));
}
@Test
void pageReturnsItemsTotalAndUnreadCount() {
UserNotificationEntity first = new UserNotificationEntity();
first.setId(11L);
first.setTitle("a");
first.setReadAt(LocalDateTime.now());
UserNotificationEntity second = new UserNotificationEntity();
second.setId(10L);
second.setTitle("b");
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);
assertThat(page.getItems()).hasSize(2);
assertThat(page.getItems().get(0).getRead()).isTrue();
assertThat(page.getItems().get(1).getRead()).isFalse();
assertThat(page.getTotal()).isEqualTo(2L);
assertThat(page.getUnreadCount()).isEqualTo(1L);
assertThat(page.getPage()).isEqualTo(1L);
assertThat(page.getPageSize()).isEqualTo(20L);
}
@Test
void pageClampsPageSizeAndSkipsQueryWhenEmpty() {
when(mapper.selectCount(any())).thenReturn(0L, 0L);
NotificationPageVo page = service.page(7L, NotificationService.AUDIENCE_ADMIN, 0, 500, true);
assertThat(page.getItems()).isEmpty();
assertThat(page.getPage()).isEqualTo(1L);
assertThat(page.getPageSize()).isEqualTo(100L);
verify(mapper, never()).selectList(any());
}
@Test
void summaryReturnsUnreadCountAndLatestId() {
UserNotificationEntity latest = new UserNotificationEntity();
latest.setId(42L);
when(mapper.selectCount(any())).thenReturn(3L);
when(mapper.selectOne(any())).thenReturn(latest);
NotificationSummaryVo summary = service.summary(7L, NotificationService.AUDIENCE_USER);
assertThat(summary.getUnreadCount()).isEqualTo(3L);
assertThat(summary.getLatestId()).isEqualTo(42L);
}
@Test
void markReadReturnsFalseWhenNothingUpdated() {
when(mapper.update(any(), any())).thenReturn(0);
assertThat(service.markRead(7L, NotificationService.AUDIENCE_USER, 5L)).isFalse();
when(mapper.update(any(), any())).thenReturn(1);
assertThat(service.markRead(7L, NotificationService.AUDIENCE_USER, 5L)).isTrue();
}
@Test
void markAllReadReturnsUpdatedCount() {
when(mapper.update(any(), any())).thenReturn(4);
assertThat(service.markAllRead(7L, NotificationService.AUDIENCE_ADMIN)).isEqualTo(4);
}
@Test
void cleanupDeletesOnlyReadRowsBeforeCutoff() {
when(mapper.delete(any())).thenReturn(2);
int deleted = service.cleanupReadBefore(LocalDateTime.now().minusDays(90));
assertThat(deleted).isEqualTo(2);
verify(mapper).delete(any());
}
}
@@ -192,6 +192,34 @@ class UserApiSecretServiceTest {
org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.save(7L, "proxy", "1.2.3.4:8080"))
.isInstanceOf(com.nanri.aiimage.common.exception.BusinessException.class)
.hasMessageContaining("代理地址格式不正确");
org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.save(7L, "proxy", "随便写点什么"))
.isInstanceOf(com.nanri.aiimage.common.exception.BusinessException.class)
.hasMessageContaining("代理地址格式不正确");
}
/** 用户界面上保存的代理多为 jikip 提取链接(无显式端口,默认 443),必须允许保存。 */
@Test
void saveAcceptsProxyExtractionLink() {
UserApiSecretService service = newService();
when(mapper.selectOne(any())).thenReturn(null);
service.save(7L, "proxy",
"https://api.jikip.com/ip-get?num=1&minute=3&format=json&area=all&protocol=1&mode=2&key=6p78gjm9c0p161o");
ArgumentCaptor<UserApiSecretEntity> captor = ArgumentCaptor.forClass(UserApiSecretEntity.class);
verify(mapper).insert(captor.capture());
assertThat(captor.getValue().getModuleKey()).isEqualTo("proxy");
assertThat(captor.getValue().getSecretValue()).contains("api.jikip.com");
}
@Test
void saveAcceptsStaticProxyWithCredentials() {
UserApiSecretService service = newService();
when(mapper.selectOne(any())).thenReturn(null);
service.save(7L, "proxy", "http://user:pass@1.2.3.4:8080");
verify(mapper).insert(any(UserApiSecretEntity.class));
}
@Test