From b70557a077bda40b4d7ca356e4e4f6ceefc8aaf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Sun, 13 Sep 2026 23:08:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=AE=A4=E8=AF=81/=E9=80=9A=E7=9F=A5):=20?= =?UTF-8?q?=E5=8D=95=E8=AE=BE=E5=A4=87=E7=99=BB=E5=BD=95=E4=BA=92=E8=B8=A2?= =?UTF-8?q?=20+=20=E7=AB=99=E5=86=85=E9=80=9A=E7=9F=A5=E9=93=83=E9=93=9B?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 单设备登录:登录成功即 last-login-wins 绑定 users.machine;非超管旧 token 在下一次受保护请求抛 4011 下线,超管豁免;仅认 token 内签名 deviceId,不用请求头。 前端两端接入踢下线跳转(?kicked=1 提示),V118 清空历史 machine - 站内通知:新增 notification 模块(任务失败扫描 / 密钥欠费 / 下游服务探测三类来源), 前后台铃铛组件 + 轮询;后台列表按主管数据范围(UserDataScopeSupport)过滤;V116 建表 均已于 2026-09-13 部署上线,此次补提交源码(此前仅存在于已部署 JAR/构建产物中) --- admin-frontend-vue/src/api/envelope.ts | 7 + admin-frontend-vue/src/api/http.ts | 27 +- admin-frontend-vue/src/api/notifications.ts | 56 ++ .../src/components/NotificationBell.vue | 451 +++++++++++++++ admin-frontend-vue/src/layout/AdminLayout.vue | 2 + .../src/layout/notification-bell-model.ts | 89 +++ .../src/pages/login/LoginPage.vue | 17 + .../tests/notification-bell.test.ts | 82 +++ .../tests/single-device-kick.test.ts | 28 + .../aiimage/config/AdminApiGuardFilter.java | 1 + .../config/NotificationProperties.java | 46 ++ .../aiimage/config/PropertiesConfig.java | 2 +- .../aiimage/config/UserSecretProperties.java | 6 + .../admin/support/AdminAuthSupport.java | 20 +- .../modules/auth/config/AuthProperties.java | 2 + .../modules/auth/service/AuthService.java | 61 +- .../auth/support/DeviceSessionPolicy.java | 90 +++ .../AdminNotificationController.java | 74 +++ .../controller/NotificationController.java | 74 +++ .../mapper/UserNotificationMapper.java | 9 + .../model/entity/UserNotificationEntity.java | 25 + .../model/vo/NotificationItemVo.java | 22 + .../model/vo/NotificationPageVo.java | 16 + .../model/vo/NotificationSummaryVo.java | 11 + .../service/NotificationDispatchService.java | 127 +++++ .../service/NotificationScanScheduler.java | 374 ++++++++++++ .../service/NotificationService.java | 252 +++++++++ .../support/UserDataScopeSupport.java | 67 +++ .../service/UserApiSecretService.java | 11 +- .../src/main/resources/application.yml | 17 + .../resources/db/V116__user_notification.sql | 21 + .../db/V118__clear_users_machine.sql | 9 + .../admin/support/AdminAuthSupportTest.java | 76 +++ .../modules/auth/service/AuthServiceTest.java | 184 ++++++ .../auth/support/DeviceSessionPolicyTest.java | 101 ++++ .../NotificationDispatchServiceTest.java | 106 ++++ .../NotificationScanSchedulerTest.java | 130 +++++ .../service/NotificationServiceTest.java | 189 +++++++ .../service/UserApiSecretServiceTest.java | 28 + .../pages/amazon/components/AmazonTopBar.vue | 2 + .../src/pages/home/DesktopHomePage.vue | 2 + .../src/pages/login/DesktopLoginPage.vue | 42 ++ .../pages/setup/DesktopSecretSetupPage.vue | 26 +- frontend-vue/src/shared/api/endpoints.ts | 6 + frontend-vue/src/shared/api/http.ts | 27 +- .../shared/api/types/modules/notification.ts | 65 +++ frontend-vue/src/shared/auth/kick-handler.ts | 89 +++ .../components/ApiSecretSettingsPanel.vue | 19 +- .../shared/components/NotificationBell.vue | 533 ++++++++++++++++++ .../src/shared/utils/notification-bell.ts | 97 ++++ frontend-vue/tests/endpoints.test.ts | 7 + frontend-vue/tests/notification-bell.test.ts | 86 +++ frontend-vue/tests/single-device-kick.test.ts | 172 ++++++ 53 files changed, 3982 insertions(+), 101 deletions(-) create mode 100644 admin-frontend-vue/src/api/notifications.ts create mode 100644 admin-frontend-vue/src/components/NotificationBell.vue create mode 100644 admin-frontend-vue/src/layout/notification-bell-model.ts create mode 100644 admin-frontend-vue/tests/notification-bell.test.ts create mode 100644 admin-frontend-vue/tests/single-device-kick.test.ts create mode 100644 backend-java/src/main/java/com/nanri/aiimage/config/NotificationProperties.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/auth/support/DeviceSessionPolicy.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/notification/controller/AdminNotificationController.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/notification/controller/NotificationController.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/notification/mapper/UserNotificationMapper.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/notification/model/entity/UserNotificationEntity.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/notification/model/vo/NotificationItemVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/notification/model/vo/NotificationPageVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/notification/model/vo/NotificationSummaryVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/notification/service/NotificationDispatchService.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/notification/service/NotificationScanScheduler.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/notification/service/NotificationService.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/permission/support/UserDataScopeSupport.java create mode 100644 backend-java/src/main/resources/db/V116__user_notification.sql create mode 100644 backend-java/src/main/resources/db/V118__clear_users_machine.sql create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/auth/service/AuthServiceTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/auth/support/DeviceSessionPolicyTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/notification/service/NotificationDispatchServiceTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/notification/service/NotificationScanSchedulerTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/notification/service/NotificationServiceTest.java create mode 100644 frontend-vue/src/shared/api/types/modules/notification.ts create mode 100644 frontend-vue/src/shared/auth/kick-handler.ts create mode 100644 frontend-vue/src/shared/components/NotificationBell.vue create mode 100644 frontend-vue/src/shared/utils/notification-bell.ts create mode 100644 frontend-vue/tests/notification-bell.test.ts create mode 100644 frontend-vue/tests/single-device-kick.test.ts diff --git a/admin-frontend-vue/src/api/envelope.ts b/admin-frontend-vue/src/api/envelope.ts index 1f85ac99..ed129159 100644 --- a/admin-frontend-vue/src/api/envelope.ts +++ b/admin-frontend-vue/src/api/envelope.ts @@ -53,6 +53,13 @@ export function isForbidden(payload: unknown): boolean { return [record.status, record.statusCode, record.code].some((v) => v === 403) } +/** 单设备登录:账号已在其他设备登录(4011,与后端 DeviceSessionPolicy.CODE_KICKED 对齐)。 */ +export function isKicked(payload: unknown): boolean { + const record = payload as { code?: unknown } | null + if (!record || typeof record !== 'object') return false + return record.code === 4011 +} + /** Axios 错误(带 response)或普通 Error 统一取可展示文案。 */ export function requestErrorMessage(error: unknown): string { const response = (error as { response?: { data?: unknown; status?: number } })?.response diff --git a/admin-frontend-vue/src/api/http.ts b/admin-frontend-vue/src/api/http.ts index a208e4f3..91bcae47 100644 --- a/admin-frontend-vue/src/api/http.ts +++ b/admin-frontend-vue/src/api/http.ts @@ -1,5 +1,12 @@ import axios from 'axios' -import { isForbidden, isUnauthorized, loginRedirectTarget, shouldRedirectUnauthorized } from './envelope' +import { + isForbidden, + isKicked, + isLoginLocation, + isUnauthorized, + loginRedirectTarget, + shouldRedirectUnauthorized, +} from './envelope' export { unwrap } from './envelope' @@ -17,15 +24,29 @@ function redirectToLogin(requestUrl?: string): void { window.location.assign('/admin-vue/login?redirect=' + target) } +/** 单设备登录:被新设备顶下线,整页回登录页(整页 reload 顺带重置会话 store,避免守卫弹回)。 */ +function redirectToLoginKicked(): void { + if (typeof window === 'undefined') return + if (isLoginLocation(window.location.pathname)) return + window.location.assign('/admin-vue/login?kicked=1') +} + http.interceptors.response.use( (response) => { // Java 未登录以 HTTP 200 + body.code=401 返回,需统一按未登录处理; // 403(已登录但无后台权限,如用工具前端账号 token 访问后台)与 401 同样跳登录页。 - if (isUnauthorized(response.data) || isForbidden(response.data)) redirectToLogin(response.config?.url) + // 4011(账号已在其他设备登录)单独提示,不按普通 401 处理。 + if (isKicked(response.data)) { + redirectToLoginKicked() + } else if (isUnauthorized(response.data) || isForbidden(response.data)) { + redirectToLogin(response.config?.url) + } return response }, (error) => { - if ( + if (isKicked(error?.response?.data)) { + redirectToLoginKicked() + } else if ( error?.response?.status === 401 || error?.response?.status === 403 || isUnauthorized(error?.response?.data) || diff --git a/admin-frontend-vue/src/api/notifications.ts b/admin-frontend-vue/src/api/notifications.ts new file mode 100644 index 00000000..0984bdc8 --- /dev/null +++ b/admin-frontend-vue/src/api/notifications.ts @@ -0,0 +1,56 @@ +import { http } from './http' +import { unwrap } from './envelope' + +/** 单条通知(后台铃铛)。 */ +export interface AdminNotificationItem { + id: number + scene: string + level: string + title: string + content: string + read: boolean + readAt: string | null + createdAt: string | null +} + +export interface AdminNotificationPage { + items: AdminNotificationItem[] + total: number + page: number + pageSize: number + unreadCount: number +} + +export interface AdminNotificationSummary { + unreadCount: number + latestId: number +} + +/** 铃铛轮询:未读数 + 最新通知 id。 */ +export async function fetchNotificationSummary(): Promise { + const { data } = await http.get('/api/admin/notifications/summary') + return unwrap(data) +} + +export async function fetchNotificationList( + params: { page?: number; pageSize?: number; onlyUnread?: boolean } = {}, +): Promise { + const { data } = await http.get('/api/admin/notifications', { + params: { + page: params.page ?? 1, + pageSize: params.pageSize ?? 20, + onlyUnread: params.onlyUnread ? 'true' : 'false', + }, + }) + return unwrap(data) +} + +export async function markNotificationRead(id: number): Promise { + const { data } = await http.post(`/api/admin/notifications/${id}/read`) + return unwrap(data) +} + +export async function markAllNotificationsRead(): Promise { + const { data } = await http.post('/api/admin/notifications/read-all') + return unwrap(data) +} diff --git a/admin-frontend-vue/src/components/NotificationBell.vue b/admin-frontend-vue/src/components/NotificationBell.vue new file mode 100644 index 00000000..9568459c --- /dev/null +++ b/admin-frontend-vue/src/components/NotificationBell.vue @@ -0,0 +1,451 @@ + + + + + diff --git a/admin-frontend-vue/src/layout/AdminLayout.vue b/admin-frontend-vue/src/layout/AdminLayout.vue index 347641f7..3e95940f 100644 --- a/admin-frontend-vue/src/layout/AdminLayout.vue +++ b/admin-frontend-vue/src/layout/AdminLayout.vue @@ -12,6 +12,7 @@ import { shouldShowEmptyMenu, } from '@/layout/empty-state' import GlobalErrorContainer from '@/layout/GlobalErrorContainer.vue' +import NotificationBell from '@/components/NotificationBell.vue' import OperationGuide from '@/layout/OperationGuide.vue' import { crumbsForActiveRoute, resolveDocumentTitle, updateDocumentTitle } from '@/layout/title-breadcrumb' import { @@ -100,6 +101,7 @@ async function signOut() {

{{ pageTitle }}

+
{{ userVm.username }}
diff --git a/admin-frontend-vue/src/layout/notification-bell-model.ts b/admin-frontend-vue/src/layout/notification-bell-model.ts new file mode 100644 index 00000000..c5dc222d --- /dev/null +++ b/admin-frontend-vue/src/layout/notification-bell-model.ts @@ -0,0 +1,89 @@ +/** + * 后台铃铛通知纯逻辑:未读徽标文案、新通知判定、「已提醒过」去重记录、时间展示。 + * 轮询调度与渲染在 NotificationBell.vue;这里只放可单测的纯函数与存储读写。 + */ + +/** 未读轮询间隔(60 秒)。 */ +export const NOTIFICATION_POLL_INTERVAL_MS = 60_000 + +const NOTIFIED_KEY_PREFIX = 'admin-notification:last-notified-id' + +/** 未读徽标文案:0 或非法值显示空串,>99 显示 99+。 */ +export function formatUnreadBadge(count: number | null | undefined): string { + const value = Number(count ?? 0) + if (!Number.isFinite(value) || value <= 0) { + return '' + } + return value > 99 ? '99+' : String(Math.floor(value)) +} + +/** 是否出现新通知:latestId 大于上次已提醒过的 id。 */ +export function hasNewNotification( + latestId: number | null | undefined, + lastNotifiedId: number | null | undefined, +): boolean { + const latest = Number(latestId ?? 0) + const notified = Number(lastNotifiedId ?? 0) + if (!Number.isFinite(latest) || latest <= 0) { + return false + } + return latest > (Number.isFinite(notified) && notified > 0 ? notified : 0) +} + +function storageKey(uid: string | number | null | undefined): string { + const normalized = uid === null || uid === undefined || String(uid).trim() === '' ? '0' : String(uid) + return `${NOTIFIED_KEY_PREFIX}:${normalized}` +} + +/** 读取当前管理员「已提醒过的最大通知 id」。 */ +export function readLastNotifiedId(uid: string | number | null | undefined): number { + try { + const raw = window.localStorage.getItem(storageKey(uid)) + const value = Number(raw ?? 0) + return Number.isFinite(value) && value > 0 ? value : 0 + } catch { + return 0 + } +} + +export function writeLastNotifiedId(uid: string | number | null | undefined, id: number): void { + try { + if (Number.isFinite(id) && id > 0) { + window.localStorage.setItem(storageKey(uid), String(Math.floor(id))) + } + } catch { + /* 存储不可用时静默忽略(退化为每次都提醒,不影响功能) */ + } +} + +/** 通知时间展示:今天只显示 HH:mm;昨天显示「昨天 HH:mm」;更早显示 MM-DD HH:mm。 */ +export function formatNotificationTime( + value: string | null | undefined, + now: Date = new Date(), +): string { + if (!value) { + return '' + } + const date = new Date(value) + if (Number.isNaN(date.getTime())) { + return '' + } + const pad = (input: number) => String(input).padStart(2, '0') + const hourMinute = `${pad(date.getHours())}:${pad(date.getMinutes())}` + const sameDay = + date.getFullYear() === now.getFullYear() && + date.getMonth() === now.getMonth() && + date.getDate() === now.getDate() + if (sameDay) { + return hourMinute + } + const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1) + const isYesterday = + date.getFullYear() === yesterday.getFullYear() && + date.getMonth() === yesterday.getMonth() && + date.getDate() === yesterday.getDate() + if (isYesterday) { + return `昨天 ${hourMinute}` + } + return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${hourMinute}` +} diff --git a/admin-frontend-vue/src/pages/login/LoginPage.vue b/admin-frontend-vue/src/pages/login/LoginPage.vue index 9c2657ec..c12e7950 100644 --- a/admin-frontend-vue/src/pages/login/LoginPage.vue +++ b/admin-frontend-vue/src/pages/login/LoginPage.vue @@ -18,6 +18,8 @@ const errorMessage = ref('') const logoUrl = joinAdminPath('assets', 'logo.jpg') const inputType = computed(() => (showPassword.value ? 'text' : 'password')) +/** 单设备登录:被新设备顶下线后跳回登录页(整页跳转携带 ?kicked=1) */ +const kickedNotice = computed(() => route.query.kicked === '1') function deviceId(): string { try { @@ -120,6 +122,7 @@ onMounted(() => {
+
@@ -296,6 +299,20 @@ button, input { font: inherit; } border: 1px solid currentColor; border-radius: 50%; font-size: 11px; font-weight: 800; } +/* 单设备登录:被新设备顶下线的提示 */ +.kicked-msg { + display: flex; align-items: flex-start; gap: 9px; + margin: -4px 0 18px; padding: 11px 12px; + border: 1px solid #e0cf9f; border-radius: 11px; background: #fbf4e2; color: #8a6a1f; + font-size: 13px; line-height: 1.55; +} +.kicked-msg::before { + content: "!"; + display: inline-flex; align-items: center; justify-content: center; + flex: 0 0 18px; width: 18px; height: 18px; + border: 1px solid currentColor; border-radius: 50%; font-size: 11px; font-weight: 800; +} + .btn-login { display: inline-flex; align-items: center; justify-content: center; gap: 9px; width: 100%; min-height: 48px; padding: 12px 18px; diff --git a/admin-frontend-vue/tests/notification-bell.test.ts b/admin-frontend-vue/tests/notification-bell.test.ts new file mode 100644 index 00000000..073ddb28 --- /dev/null +++ b/admin-frontend-vue/tests/notification-bell.test.ts @@ -0,0 +1,82 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + formatNotificationTime, + formatUnreadBadge, + hasNewNotification, + readLastNotifiedId, + writeLastNotifiedId, +} from '../src/layout/notification-bell-model.ts' + +function createStorage() { + const store = new Map() + return { + getItem: (key: string) => (store.has(key) ? (store.get(key) as string) : null), + setItem: (key: string, value: string) => { + store.set(key, String(value)) + }, + removeItem: (key: string) => { + store.delete(key) + }, + } +} + +function setupWindow() { + const localStorage = createStorage() + const globalScope = globalThis as Record + const previous = globalScope.window + globalScope.window = { localStorage } + return { + localStorage, + restore: () => { + globalScope.window = previous + }, + } +} + +test('test_未读徽标:0 与非法值不显示、超过 99 显示 99+', () => { + assert.equal(formatUnreadBadge(0), '') + assert.equal(formatUnreadBadge(-3), '') + assert.equal(formatUnreadBadge(null), '') + assert.equal(formatUnreadBadge(undefined), '') + assert.equal(formatUnreadBadge(Number.NaN), '') + assert.equal(formatUnreadBadge(1), '1') + assert.equal(formatUnreadBadge(99), '99') + assert.equal(formatUnreadBadge(100), '99+') +}) + +test('test_新通知判定:latestId 需大于已提醒 id', () => { + assert.equal(hasNewNotification(0, 0), false) + assert.equal(hasNewNotification(null, 5), false) + assert.equal(hasNewNotification(5, 5), false) + assert.equal(hasNewNotification(4, 5), false) + assert.equal(hasNewNotification(6, 5), true) + assert.equal(hasNewNotification(6, 0), true) + assert.equal(hasNewNotification(6, null), true) +}) + +test('test_已提醒 id 按管理员读写并容错', () => { + setupWindow() + assert.equal(readLastNotifiedId(7), 0, '未写入时按 0 处理') + + writeLastNotifiedId(7, 88) + assert.equal(readLastNotifiedId(7), 88) + assert.equal(readLastNotifiedId(8), 0, '不同管理员互不影响') + + writeLastNotifiedId(7, 0) + assert.equal(readLastNotifiedId(7), 88, '非法 id 不覆盖已有值') + + window.localStorage.setItem('admin-notification:last-notified-id:7', 'broken') + assert.equal(readLastNotifiedId(7), 0, '损坏值按 0 处理') + + assert.equal(readLastNotifiedId(null), readLastNotifiedId('0'), '空 uid 归一为 0 号键') +}) + +test('test_时间展示:今天/昨天/更早', () => { + const now = new Date(2026, 8, 13, 15, 30) + assert.equal(formatNotificationTime(new Date(2026, 8, 13, 9, 5).toISOString(), now), '09:05') + assert.equal(formatNotificationTime(new Date(2026, 8, 12, 23, 59).toISOString(), now), '昨天 23:59') + assert.equal(formatNotificationTime(new Date(2026, 8, 1, 8, 0).toISOString(), now), '09-01 08:00') + assert.equal(formatNotificationTime(null, now), '') + assert.equal(formatNotificationTime('not-a-date', now), '') +}) diff --git a/admin-frontend-vue/tests/single-device-kick.test.ts b/admin-frontend-vue/tests/single-device-kick.test.ts new file mode 100644 index 00000000..9f032aa0 --- /dev/null +++ b/admin-frontend-vue/tests/single-device-kick.test.ts @@ -0,0 +1,28 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readSource } from './helpers.ts' +import { isForbidden, isKicked, isUnauthorized } from '../src/api/envelope.ts' + +test('单设备登录:4011 只认业务码,不被 401/403 判定吞掉', () => { + assert.equal(isKicked({ code: 4011 }), true) + assert.equal(isKicked({ code: 401 }), false) + assert.equal(isKicked({ status: 4011 }), false) + assert.equal(isKicked(null), false) + assert.equal(isKicked('4011'), false) + // 4011 与普通 401/403 是两种处理(提示"已在其他设备登录" vs 跳登录页),不能误判 + assert.equal(isUnauthorized({ code: 4011 }), false) + assert.equal(isForbidden({ code: 4011 }), false) +}) + +test('单设备登录:http 拦截器接线(两分支先判 4011,跳带 kicked 标记的登录页)', () => { + const http = readSource('src/api/http.ts') + assert.match(http, /isKicked/) + assert.match(http, /\/admin-vue\/login\?kicked=1/) +}) + +test('单设备登录:登录页显示被顶下线提示', () => { + const page = readSource('src/pages/login/LoginPage.vue') + assert.match(page, /kickedNotice/) + assert.match(page, /该账号已在其他设备登录/) + assert.match(page, /kicked-msg/) +}) diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/AdminApiGuardFilter.java b/backend-java/src/main/java/com/nanri/aiimage/config/AdminApiGuardFilter.java index fe8df5c9..aee32331 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/AdminApiGuardFilter.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/AdminApiGuardFilter.java @@ -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; diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/NotificationProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/NotificationProperties.java new file mode 100644 index 00000000..363f6492 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/config/NotificationProperties.java @@ -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; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/PropertiesConfig.java b/backend-java/src/main/java/com/nanri/aiimage/config/PropertiesConfig.java index 3b7e80bf..34a4026d 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/PropertiesConfig.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/PropertiesConfig.java @@ -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 { } diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/UserSecretProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/UserSecretProperties.java index e0a6c3e0..b6a79145 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/UserSecretProperties.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/UserSecretProperties.java @@ -36,4 +36,10 @@ public class UserSecretProperties { /** jikip 用户 ID(余量查询参数)。 */ private String jikipUserId = ""; + + /** + * 巡检发现欠费/密钥失效时是否推送站内通知(桌面端用户 + 后台管理员); + * 关闭后巡检只更新检测状态、不发通知(应急降噪开关)。 + */ + private boolean notifyEnabled = true; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/admin/support/AdminAuthSupport.java b/backend-java/src/main/java/com/nanri/aiimage/modules/admin/support/AdminAuthSupport.java index 3c008dd0..5a20c467 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/admin/support/AdminAuthSupport.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/admin/support/AdminAuthSupport.java @@ -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)回退,仍要求管理员角色。 */ diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/auth/config/AuthProperties.java b/backend-java/src/main/java/com/nanri/aiimage/modules/auth/config/AuthProperties.java index e3ee4040..54a44e18 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/auth/config/AuthProperties.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/auth/config/AuthProperties.java @@ -14,4 +14,6 @@ public class AuthProperties { private String cookieName = "aiimage_token"; private boolean cookieSecure = false; private String cookieSameSite = "Lax"; + /** 单设备登录(互踢)总开关:关闭后恢复为多设备同时在线(回滚用)。 */ + private boolean singleDeviceEnabled = true; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/auth/service/AuthService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/auth/service/AuthService.java index 76853666..802d9624 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/auth/service/AuthService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/auth/service/AuthService.java @@ -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() .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() - .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) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/auth/support/DeviceSessionPolicy.java b/backend-java/src/main/java/com/nanri/aiimage/modules/auth/support/DeviceSessionPolicy.java new file mode 100644 index 00000000..7e5ac418 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/auth/support/DeviceSessionPolicy.java @@ -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; + +/** + * 单设备登录(互踢)策略。 + * + *

账号当前绑定的设备存放在 users.machine(登录成功即覆盖,last-login-wins); + * 非超管账号仅允许「token 内签名的 deviceId」与绑定设备一致的请求通过, + * 被新设备顶下线的旧 token 在下一次受保护请求时抛 4011。

+ * + *

校验只认 token 内签名的 deviceId,绝不使用 X-Device-Id 请求头(头是客户端可控的)。

+ */ +@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); + } + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/notification/controller/AdminNotificationController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/notification/controller/AdminNotificationController.java new file mode 100644 index 00000000..b17cdbec --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/notification/controller/AdminNotificationController.java @@ -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 summary(HttpServletRequest request) { + Long userId = currentAdminId(request); + return ApiResponse.success(notificationService.summary(userId, NotificationService.AUDIENCE_ADMIN)); + } + + @GetMapping + @Operation(summary = "通知分页列表") + public ApiResponse 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 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 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(); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/notification/controller/NotificationController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/notification/controller/NotificationController.java new file mode 100644 index 00000000..c94be8dc --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/notification/controller/NotificationController.java @@ -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 summary(HttpServletRequest request) { + Long userId = currentUserId(request); + return ApiResponse.success(notificationService.summary(userId, NotificationService.AUDIENCE_USER)); + } + + @GetMapping + @Operation(summary = "通知分页列表", description = "onlyUnread=true 时只返回未读;附带未读总数。") + public ApiResponse 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 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 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(); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/notification/mapper/UserNotificationMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/notification/mapper/UserNotificationMapper.java new file mode 100644 index 00000000..545ff2a9 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/notification/mapper/UserNotificationMapper.java @@ -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 { +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/notification/model/entity/UserNotificationEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/notification/model/entity/UserNotificationEntity.java new file mode 100644 index 00000000..1162c478 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/notification/model/entity/UserNotificationEntity.java @@ -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; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/notification/model/vo/NotificationItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/notification/model/vo/NotificationItemVo.java new file mode 100644 index 00000000..c9dc8f33 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/notification/model/vo/NotificationItemVo.java @@ -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; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/notification/model/vo/NotificationPageVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/notification/model/vo/NotificationPageVo.java new file mode 100644 index 00000000..3bb3c7c7 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/notification/model/vo/NotificationPageVo.java @@ -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 items; + private Long total; + private Long page; + private Long pageSize; + private Long unreadCount; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/notification/model/vo/NotificationSummaryVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/notification/model/vo/NotificationSummaryVo.java new file mode 100644 index 00000000..c018e594 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/notification/model/vo/NotificationSummaryVo.java @@ -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; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/notification/service/NotificationDispatchService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/notification/service/NotificationDispatchService.java new file mode 100644 index 00000000..6c804241 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/notification/service/NotificationDispatchService.java @@ -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 candidates = adminUserMapper.selectList(new LambdaQueryWrapper() + .eq(AdminUserEntity::getIsAdmin, 1) + .last("limit " + ADMIN_LIMIT)); + List admins = new ArrayList<>(); + Set superAdminIds = new HashSet<>(); + Map> 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 admins, + Set superAdminIds, + Map> visibleByAdmin) { + + /** 该管理员是否可接收主体用户为 subjectUserId 的事件;subjectUserId=null 为全局事件。 */ + public boolean canReceive(Long adminId, Long subjectUserId) { + if (subjectUserId == null || superAdminIds.contains(adminId)) { + return true; + } + Set visible = visibleByAdmin.get(adminId); + return visible != null && visible.contains(subjectUserId); + } + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/notification/service/NotificationScanScheduler.java b/backend-java/src/main/java/com/nanri/aiimage/modules/notification/service/NotificationScanScheduler.java new file mode 100644 index 00000000..5babe247 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/notification/service/NotificationScanScheduler.java @@ -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; + +/** + * 站内通知扫描:任务失败聚合提醒 + 下游服务健康探测。 + * + *

任务失败:回看窗口内进入失败终态的任务(biz_file_task / brand_crawl_tasks), + * 按「用户 × 模块 × 小时」聚合,同一小时桶内增量刷新同一条通知(不刷屏); + * 用户侧推给任务创建者,管理员侧按数据权限推给管辖该用户的管理员。 + * + *

服务探测:品牌检测服务(15126) / 跟价任务 API(18960) / jikip 代理接口, + * 失败立即重试一次(过滤瞬抖),两次都失败才告警;同服务每小时最多一条。 + * + *

双实例经 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 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 failed = new ArrayList<>(); + List fileTasks = fileTaskMapper.selectList(new LambdaQueryWrapper() + .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 brandTasks = brandCrawlTaskMapper.selectList(new LambdaQueryWrapper() + .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> 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> entry : buckets.entrySet()) { + BucketKey key = entry.getKey(); + List 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 tasks) { + List 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 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) { + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/notification/service/NotificationService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/notification/service/NotificationService.java new file mode 100644 index 00000000..28a4099a --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/notification/service/NotificationService.java @@ -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() + .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 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 rows = total == 0 ? List.of() + : userNotificationMapper.selectList(baseWrapper(userId, audience, onlyUnread) + .orderByDesc(UserNotificationEntity::getId) + .last("limit " + offset + "," + safeSize)); + + NotificationPageVo vo = new NotificationPageVo(); + List 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() + .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() + .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() + .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() + .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() + .isNotNull(UserNotificationEntity::getReadAt) + .lt(UserNotificationEntity::getCreatedAt, cutoff)); + if (deleted > 0) { + log.info("[notification] 清理历史已读通知 cutoff={} 删除={} 条", cutoff, deleted); + } + return deleted; + } + + private LambdaQueryWrapper baseWrapper(Long userId, String audience, boolean onlyUnread) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .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() + .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); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/support/UserDataScopeSupport.java b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/support/UserDataScopeSupport.java new file mode 100644 index 00000000..7d31200e --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/support/UserDataScopeSupport.java @@ -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 resolveVisibleUserIds(Long operatorId) { + if (operatorId == null) { + return List.of(); + } + List ledGroupIds = listLedGroupIds(operatorId); + Set userIds = new LinkedHashSet<>(); + for (Long groupId : ledGroupIds) { + userIds.addAll(shopManageGroupMapper.selectUserIdsByGroupId(groupId)); + } + // 没有分组记录的主管(历史数据)回退按「名下子账户」兜底,避免可见范围整体为空。 + if (userIds.isEmpty()) { + adminUserMapper.selectList(new LambdaQueryWrapper() + .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 listLedGroupIds(Long operatorId) { + if (operatorId == null) { + return List.of(); + } + return shopManageGroupMapper.selectLedGroups(operatorId).stream() + .map(ShopManageGroupEntity::getId) + .filter(id -> id != null && id > 0) + .toList(); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretService.java index 0709ff09..28da64f9 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretService.java @@ -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) { diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml index a193bddb..54fe2434 100644 --- a/backend-java/src/main/resources/application.yml +++ b/backend-java/src/main/resources/application.yml @@ -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} diff --git a/backend-java/src/main/resources/db/V116__user_notification.sql b/backend-java/src/main/resources/db/V116__user_notification.sql new file mode 100644 index 00000000..b499b94a --- /dev/null +++ b/backend-java/src/main/resources/db/V116__user_notification.sql @@ -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='站内通知(桌面端用户 + 后台管理员)'; diff --git a/backend-java/src/main/resources/db/V118__clear_users_machine.sql b/backend-java/src/main/resources/db/V118__clear_users_machine.sql new file mode 100644 index 00000000..4f1864a0 --- /dev/null +++ b/backend-java/src/main/resources/db/V118__clear_users_machine.sql @@ -0,0 +1,9 @@ +-- V118: 清空 users.machine,为单设备登录(互踢)做干净起点 +-- 背景:users.machine 是早期「设备绑定」遗留列,绑定逻辑停用后一直没维护, +-- 存量值与用户当前设备普遍不一致。单设备登录上线后校验 machine 与 +-- token 内签名 deviceId 是否一致,若不清空,历史残留会导致用户部署后 +-- 立刻被判「已在其他设备登录」而下线(提示还是错的)。 +-- 清空后:已登录用户不受影响,每人下一次登录时写入当前设备,自然进入新规则。 +-- 幂等:只清非空值,重复执行无副作用。 + +UPDATE `users` SET `machine` = NULL WHERE `machine` IS NOT NULL; diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/admin/support/AdminAuthSupportTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/admin/support/AdminAuthSupportTest.java index 422155b0..48b194dc 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/admin/support/AdminAuthSupportTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/admin/support/AdminAuthSupportTest.java @@ -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; } } diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/auth/service/AuthServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/auth/service/AuthServiceTest.java new file mode 100644 index 00000000..e72eac24 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/auth/service/AuthServiceTest.java @@ -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; + } + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/auth/support/DeviceSessionPolicyTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/auth/support/DeviceSessionPolicyTest.java new file mode 100644 index 00000000..1d8a8cb0 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/auth/support/DeviceSessionPolicyTest.java @@ -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"); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/notification/service/NotificationDispatchServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/notification/service/NotificationDispatchServiceTest.java new file mode 100644 index 00000000..f2a65eff --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/notification/service/NotificationDispatchServiceTest.java @@ -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; + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/notification/service/NotificationScanSchedulerTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/notification/service/NotificationScanSchedulerTest.java new file mode 100644 index 00000000..b133ce33 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/notification/service/NotificationScanSchedulerTest.java @@ -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 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 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; + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/notification/service/NotificationServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/notification/service/NotificationServiceTest.java new file mode 100644 index 00000000..30a6b8d1 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/notification/service/NotificationServiceTest.java @@ -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 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()); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretServiceTest.java index 311b1417..510ab70a 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretServiceTest.java @@ -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 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 diff --git a/frontend-vue/src/pages/amazon/components/AmazonTopBar.vue b/frontend-vue/src/pages/amazon/components/AmazonTopBar.vue index 2404bf3a..ac5f4f82 100644 --- a/frontend-vue/src/pages/amazon/components/AmazonTopBar.vue +++ b/frontend-vue/src/pages/amazon/components/AmazonTopBar.vue @@ -29,6 +29,7 @@

+ {{ username }}
@@ -39,6 +40,7 @@ import { computed, onMounted, ref } from 'vue' import BrandApiSecretSettingsButton from '@/pages/brand/components/BrandApiSecretSettingsButton.vue' +import NotificationBell from '@/shared/components/NotificationBell.vue' import { filterGroupsByPermission, TOOL_GROUPS } from '@/pages/amazon/tool-catalog' import type { VisibleGroup } from '@/pages/amazon/tool-catalog' import { resolvePageHref } from '@/shared/page-prefix' diff --git a/frontend-vue/src/pages/home/DesktopHomePage.vue b/frontend-vue/src/pages/home/DesktopHomePage.vue index da9a62e6..f1aaf34c 100644 --- a/frontend-vue/src/pages/home/DesktopHomePage.vue +++ b/frontend-vue/src/pages/home/DesktopHomePage.vue @@ -30,6 +30,7 @@
+ {{ username || '未登录' }} 退出 @@ -63,6 +64,7 @@ import { restoreLoginUser } from '@/shared/auth/ensure-auth' import { getCurrentUserAppColumnRaw, readCachedAppColumnPermissions, type PermissionMenuItem } from '@/shared/api/permission' import { useVersionUpdate } from '@/shared/composables/useVersionUpdate' import UpdateProgressBar from '@/shared/components/UpdateProgressBar.vue' +import NotificationBell from '@/shared/components/NotificationBell.vue' import { resolvePageHref } from '@/shared/page-prefix' const username = ref('') diff --git a/frontend-vue/src/pages/login/DesktopLoginPage.vue b/frontend-vue/src/pages/login/DesktopLoginPage.vue index af4a6279..b12b4e45 100644 --- a/frontend-vue/src/pages/login/DesktopLoginPage.vue +++ b/frontend-vue/src/pages/login/DesktopLoginPage.vue @@ -6,6 +6,7 @@