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:
+76
@@ -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() {
|
||||
// 账号已绑定 devA,token 内签名的设备是 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;
|
||||
}
|
||||
}
|
||||
|
||||
+184
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+101
@@ -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");
|
||||
}
|
||||
}
|
||||
+106
@@ -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;
|
||||
}
|
||||
}
|
||||
+130
@@ -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;
|
||||
}
|
||||
}
|
||||
+189
@@ -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());
|
||||
}
|
||||
}
|
||||
+28
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user