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:
@@ -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;
|
||||
}
|
||||
|
||||
+8
-12
@@ -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;
|
||||
}
|
||||
|
||||
+16
-45
@@ -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) {
|
||||
|
||||
+90
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
@@ -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();
|
||||
}
|
||||
}
|
||||
+74
@@ -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();
|
||||
}
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+25
@@ -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;
|
||||
}
|
||||
+22
@@ -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;
|
||||
}
|
||||
+16
@@ -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;
|
||||
}
|
||||
+11
@@ -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;
|
||||
}
|
||||
+127
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+374
@@ -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) / 跟价任务 API(18960) / 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) {
|
||||
}
|
||||
}
|
||||
+252
@@ -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);
|
||||
}
|
||||
}
|
||||
+67
@@ -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);
|
||||
}
|
||||
|
||||
/** 主管带的分组 ID(created_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();
|
||||
}
|
||||
}
|
||||
+8
-3
@@ -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 '接收者ID(users.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;
|
||||
+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