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
@@ -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