feat(认证/通知): 单设备登录互踢 + 站内通知铃铛系统
- 单设备登录:登录成功即 last-login-wins 绑定 users.machine;非超管旧 token 在下一次受保护请求抛 4011 下线,超管豁免;仅认 token 内签名 deviceId,不用请求头。 前端两端接入踢下线跳转(?kicked=1 提示),V118 清空历史 machine - 站内通知:新增 notification 模块(任务失败扫描 / 密钥欠费 / 下游服务探测三类来源), 前后台铃铛组件 + 轮询;后台列表按主管数据范围(UserDataScopeSupport)过滤;V116 建表 均已于 2026-09-13 部署上线,此次补提交源码(此前仅存在于已部署 JAR/构建产物中)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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<AdminNotificationSummary> {
|
||||
const { data } = await http.get('/api/admin/notifications/summary')
|
||||
return unwrap<AdminNotificationSummary>(data)
|
||||
}
|
||||
|
||||
export async function fetchNotificationList(
|
||||
params: { page?: number; pageSize?: number; onlyUnread?: boolean } = {},
|
||||
): Promise<AdminNotificationPage> {
|
||||
const { data } = await http.get('/api/admin/notifications', {
|
||||
params: {
|
||||
page: params.page ?? 1,
|
||||
pageSize: params.pageSize ?? 20,
|
||||
onlyUnread: params.onlyUnread ? 'true' : 'false',
|
||||
},
|
||||
})
|
||||
return unwrap<AdminNotificationPage>(data)
|
||||
}
|
||||
|
||||
export async function markNotificationRead(id: number): Promise<boolean> {
|
||||
const { data } = await http.post(`/api/admin/notifications/${id}/read`)
|
||||
return unwrap<boolean>(data)
|
||||
}
|
||||
|
||||
export async function markAllNotificationsRead(): Promise<number> {
|
||||
const { data } = await http.post('/api/admin/notifications/read-all')
|
||||
return unwrap<number>(data)
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
<template>
|
||||
<div ref="rootRef" class="admin-notification-bell">
|
||||
<button
|
||||
type="button"
|
||||
class="bell-trigger"
|
||||
:class="{ 'bell-trigger--active': panelOpen }"
|
||||
:title="unreadCount > 0 ? `通知(${unreadCount} 条未读)` : '通知'"
|
||||
aria-label="通知"
|
||||
@click="togglePanel"
|
||||
>
|
||||
<svg
|
||||
class="bell-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
|
||||
</svg>
|
||||
<span v-if="badgeText" class="bell-badge">{{ badgeText }}</span>
|
||||
</button>
|
||||
|
||||
<div v-if="panelOpen" class="bell-panel" role="dialog" aria-label="通知列表">
|
||||
<div class="bell-panel-head">
|
||||
<span class="bell-panel-title">通知</span>
|
||||
<button
|
||||
v-if="unreadCount > 0"
|
||||
type="button"
|
||||
class="bell-read-all"
|
||||
:disabled="markingAll"
|
||||
@click="readAll"
|
||||
>
|
||||
{{ markingAll ? '处理中...' : '全部已读' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && !items.length" class="bell-empty">正在加载...</div>
|
||||
<div v-else-if="loadError && !items.length" class="bell-empty bell-empty--error">{{ loadError }}</div>
|
||||
<div v-else-if="!items.length" class="bell-empty">暂无通知</div>
|
||||
<ul v-else class="bell-list">
|
||||
<li
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="bell-item"
|
||||
:class="[`bell-item--${item.level || 'warning'}`, { 'bell-item--unread': !item.read }]"
|
||||
@click="onItemClick(item)"
|
||||
>
|
||||
<div class="bell-item-head">
|
||||
<span class="bell-item-title">{{ item.title }}</span>
|
||||
<span class="bell-item-time">{{ formatTime(item.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="bell-item-content">{{ item.content }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="bell-panel-foot">
|
||||
<button v-if="hasMore" type="button" class="bell-more" :disabled="loading" @click="loadMore">
|
||||
{{ loading ? '加载中...' : '加载更多' }}
|
||||
</button>
|
||||
<span v-else-if="items.length" class="bell-foot-note">已显示全部</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import {
|
||||
fetchNotificationList,
|
||||
fetchNotificationSummary,
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead,
|
||||
type AdminNotificationItem,
|
||||
} from '@/api/notifications'
|
||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||
import {
|
||||
NOTIFICATION_POLL_INTERVAL_MS,
|
||||
formatNotificationTime,
|
||||
formatUnreadBadge,
|
||||
hasNewNotification,
|
||||
readLastNotifiedId,
|
||||
writeLastNotifiedId,
|
||||
} from '@/layout/notification-bell-model'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const session = useAdminSessionStore()
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const unreadCount = ref(0)
|
||||
const items = ref<AdminNotificationItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const panelOpen = ref(false)
|
||||
const loading = ref(false)
|
||||
const markingAll = ref(false)
|
||||
const loadError = ref('')
|
||||
|
||||
const badgeText = computed(() => formatUnreadBadge(unreadCount.value))
|
||||
const hasMore = computed(() => items.value.length < total.value)
|
||||
|
||||
let pollTimer: number | null = null
|
||||
|
||||
function currentUid(): number | string {
|
||||
return session.user?.id ?? 0
|
||||
}
|
||||
|
||||
/** 拉未读数:发现新通知提醒一次(localStorage 记录已提醒过的最大 id,避免重复弹)。 */
|
||||
async function refreshSummary() {
|
||||
try {
|
||||
const summary = await fetchNotificationSummary()
|
||||
unreadCount.value = Number(summary?.unreadCount ?? 0)
|
||||
const latestId = Number(summary?.latestId ?? 0)
|
||||
if (hasNewNotification(latestId, readLastNotifiedId(currentUid()))) {
|
||||
ElMessage.warning('收到新的告警通知,请点击右上角铃铛查看')
|
||||
writeLastNotifiedId(currentUid(), latestId)
|
||||
console.log('[admin-notification] 检测到新通知 latestId=', latestId)
|
||||
}
|
||||
} catch (error) {
|
||||
// 通知接口失败静默降级:不显示红点、不打扰使用者
|
||||
console.warn('[admin-notification] 未读数刷新失败(静默降级):', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPage(targetPage: number) {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await fetchNotificationList({ page: targetPage, pageSize: PAGE_SIZE })
|
||||
const list = Array.isArray(result?.items) ? result.items : []
|
||||
items.value = targetPage <= 1 ? list : [...items.value, ...list]
|
||||
total.value = Number(result?.total ?? items.value.length)
|
||||
unreadCount.value = Number(result?.unreadCount ?? unreadCount.value)
|
||||
page.value = targetPage
|
||||
loadError.value = ''
|
||||
if (targetPage <= 1) {
|
||||
const latest = items.value.length ? Number(items.value[0].id) : 0
|
||||
if (latest > 0) {
|
||||
writeLastNotifiedId(currentUid(), Math.max(readLastNotifiedId(currentUid()), latest))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 列表加载失败在面板内提示,不弹全局消息打扰用户
|
||||
console.warn('[admin-notification] 通知列表加载失败:', error)
|
||||
loadError.value = error instanceof Error ? error.message : '通知加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (!hasMore.value) return
|
||||
void loadPage(page.value + 1)
|
||||
}
|
||||
|
||||
function togglePanel() {
|
||||
panelOpen.value = !panelOpen.value
|
||||
if (panelOpen.value) {
|
||||
void loadPage(1)
|
||||
}
|
||||
}
|
||||
|
||||
async function onItemClick(item: AdminNotificationItem) {
|
||||
if (item.read) return
|
||||
try {
|
||||
await markNotificationRead(item.id)
|
||||
item.read = true
|
||||
unreadCount.value = Math.max(0, unreadCount.value - 1)
|
||||
} catch (error) {
|
||||
console.warn('[admin-notification] 标记已读失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function readAll() {
|
||||
if (markingAll.value || unreadCount.value === 0) return
|
||||
markingAll.value = true
|
||||
try {
|
||||
await markAllNotificationsRead()
|
||||
for (const item of items.value) {
|
||||
item.read = true
|
||||
}
|
||||
unreadCount.value = 0
|
||||
} catch (error) {
|
||||
console.warn('[admin-notification] 全部已读失败:', error)
|
||||
ElMessage.error(error instanceof Error ? error.message : '操作失败')
|
||||
} finally {
|
||||
markingAll.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(value: string | null) {
|
||||
return formatNotificationTime(value)
|
||||
}
|
||||
|
||||
/** 点击组件外部关闭面板。 */
|
||||
function onDocumentMouseDown(event: MouseEvent) {
|
||||
if (!panelOpen.value) return
|
||||
const root = rootRef.value
|
||||
if (root && event.target instanceof Node && !root.contains(event.target)) {
|
||||
panelOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 页面回到前台时立即刷新一次未读数。 */
|
||||
function onVisibilityChange() {
|
||||
if (typeof document !== 'undefined' && document.visibilityState === 'visible') {
|
||||
void refreshSummary()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousedown', onDocumentMouseDown)
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
void refreshSummary()
|
||||
pollTimer = window.setInterval(() => {
|
||||
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
|
||||
return
|
||||
}
|
||||
void refreshSummary()
|
||||
}, NOTIFICATION_POLL_INTERVAL_MS)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousedown', onDocumentMouseDown)
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
if (pollTimer != null) {
|
||||
window.clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-notification-bell {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bell-trigger {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: var(--admin-muted, #5b6f83);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.bell-trigger:hover,
|
||||
.bell-trigger--active {
|
||||
background: #edf5fb;
|
||||
border-color: #cbd9e6;
|
||||
color: var(--admin-primary-strong, #2f5d8b);
|
||||
}
|
||||
|
||||
.bell-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.bell-badge {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
right: 0;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 999px;
|
||||
background: #d64545;
|
||||
color: #ffffff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bell-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
right: 0;
|
||||
z-index: 3200;
|
||||
width: 380px;
|
||||
max-width: calc(100vw - 32px);
|
||||
max-height: 460px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--admin-border, #d8e3ee);
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 18px 48px rgba(39, 67, 94, 0.22);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bell-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid #edf1f5;
|
||||
}
|
||||
|
||||
.bell-panel-title {
|
||||
color: var(--admin-text, #24384d);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bell-read-all {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--admin-primary-strong, #2f5d8b);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.bell-read-all:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.bell-empty {
|
||||
padding: 36px 0;
|
||||
color: #8a99a8;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bell-empty--error {
|
||||
color: #b23c3c;
|
||||
}
|
||||
|
||||
.bell-list {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.bell-item {
|
||||
padding: 11px 14px;
|
||||
border-bottom: 1px solid #f0f3f7;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
.bell-item:hover {
|
||||
background: #f7f9fc;
|
||||
}
|
||||
|
||||
.bell-item--unread {
|
||||
background: #f2f6ff;
|
||||
}
|
||||
|
||||
.bell-item--unread .bell-item-title::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin-right: 7px;
|
||||
border-radius: 50%;
|
||||
background: #d64545;
|
||||
vertical-align: 1px;
|
||||
}
|
||||
|
||||
.bell-item-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bell-item-title {
|
||||
color: var(--admin-text, #24384d);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bell-item--error .bell-item-title {
|
||||
color: #b23c3c;
|
||||
}
|
||||
|
||||
.bell-item--warning .bell-item-title {
|
||||
color: #a8793e;
|
||||
}
|
||||
|
||||
.bell-item--info .bell-item-title {
|
||||
color: var(--admin-primary-strong, #2f5d8b);
|
||||
}
|
||||
|
||||
.bell-item-time {
|
||||
flex-shrink: 0;
|
||||
color: #9aa6b3;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.bell-item-content {
|
||||
margin-top: 4px;
|
||||
color: #667085;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.bell-panel-foot {
|
||||
border-top: 1px solid #edf1f5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bell-more {
|
||||
width: 100%;
|
||||
padding: 9px 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--admin-primary-strong, #2f5d8b);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bell-more:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.bell-foot-note {
|
||||
display: block;
|
||||
padding: 8px 0;
|
||||
color: #9aa6b3;
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
@@ -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() {
|
||||
<h1>{{ pageTitle }}</h1>
|
||||
</div>
|
||||
<div class="admin-user">
|
||||
<NotificationBell />
|
||||
<div class="admin-user-meta">
|
||||
<strong>{{ userVm.username }}</strong>
|
||||
</div>
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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(() => {
|
||||
<p class="login-subtitle">使用管理员账号进入数富AI运营后台。</p>
|
||||
</header>
|
||||
<form novalidate @submit.prevent="submit">
|
||||
<p v-if="kickedNotice" class="kicked-msg" role="alert">该账号已在其他设备登录,本设备已下线。如非本人操作,请及时修改密码。</p>
|
||||
<p v-if="errorMessage" class="error-msg" role="alert">{{ errorMessage }}</p>
|
||||
<div class="form-group">
|
||||
<label for="loginUsername">用户名</label>
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, string>()
|
||||
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<string, unknown>
|
||||
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), '')
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -71,6 +71,7 @@ public class AdminApiGuardFilter extends OncePerRequestFilter {
|
||||
*/
|
||||
private static final String[] SELF_SERVICE_PREFIXES = {
|
||||
"/api/user-secrets",
|
||||
"/api/notifications",
|
||||
};
|
||||
|
||||
private final AdminAuthSupport adminAuthSupport;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.nanri.aiimage.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* 站内通知(铃铛)扫描与探测配置:
|
||||
* 任务失败扫描、下游服务健康探测、已读通知保留期。
|
||||
*/
|
||||
@Data
|
||||
@ConfigurationProperties(prefix = "aiimage.notification")
|
||||
public class NotificationProperties {
|
||||
|
||||
/** 扫描总开关:关闭后任务失败扫描与服务探测都不执行(应急降噪)。 */
|
||||
private boolean scanEnabled = true;
|
||||
|
||||
/** 扫描间隔(毫秒),默认 5 分钟(任务失败聚合按小时去重,高频扫描不会重复提醒)。 */
|
||||
private long scanIntervalMs = 5 * 60 * 1000L;
|
||||
|
||||
/** 首次执行延迟(毫秒),默认 2 分钟,避开启动阶段的数据库压力。 */
|
||||
private long scanInitialDelayMs = 2 * 60 * 1000L;
|
||||
|
||||
/** 任务失败扫描开关。 */
|
||||
private boolean taskScanEnabled = true;
|
||||
|
||||
/** 任务失败回看窗口(分钟):窗口内进入失败终态的任务参与聚合。 */
|
||||
private int taskFailedWindowMinutes = 60;
|
||||
|
||||
/** 单轮任务扫描最多处理条数(超出下一轮继续,避免大表拖垮扫描)。 */
|
||||
private int taskScanMaxRows = 1000;
|
||||
|
||||
/** 服务健康探测开关。 */
|
||||
private boolean serviceProbeEnabled = true;
|
||||
|
||||
/** 品牌检测服务地址(主机A 15126,探测 /api/version);留空=跳过该项探测。 */
|
||||
private String brandServiceUrl = "";
|
||||
|
||||
/** 跟价任务 API 地址(主机B 18960,探测根路径);留空=跳过该项探测。 */
|
||||
private String priceTrackApiUrl = "";
|
||||
|
||||
/** jikip 代理接口探测开关(复用余额查询接口判活,使用 user-secret 的 jikip 配置)。 */
|
||||
private boolean jikipProbeEnabled = true;
|
||||
|
||||
/** 已读通知保留天数(超期自动清理),默认 90 天。 */
|
||||
private int readRetentionDays = 90;
|
||||
}
|
||||
@@ -4,6 +4,6 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties({OssProperties.class, TransientStorageProperties.class, StorageProperties.class, BrandProgressProperties.class, DeleteBrandProgressProperties.class, BrandCheckProperties.class, ZiniaoProperties.class, ModuleCleanupProperties.class, TaskPressureProperties.class, TaskImageCacheCleanupProperties.class, AppearancePatentProperties.class, SimilarAsinProperties.class, ImageVideoProperties.class, InstanceRoutingProperties.class, CapacityPlanProperties.class, UserSecretProperties.class})
|
||||
@EnableConfigurationProperties({OssProperties.class, TransientStorageProperties.class, StorageProperties.class, BrandProgressProperties.class, DeleteBrandProgressProperties.class, BrandCheckProperties.class, ZiniaoProperties.class, ModuleCleanupProperties.class, TaskPressureProperties.class, TaskImageCacheCleanupProperties.class, AppearancePatentProperties.class, SimilarAsinProperties.class, ImageVideoProperties.class, InstanceRoutingProperties.class, CapacityPlanProperties.class, UserSecretProperties.class, NotificationProperties.class})
|
||||
public class PropertiesConfig {
|
||||
}
|
||||
|
||||
@@ -36,4 +36,10 @@ public class UserSecretProperties {
|
||||
|
||||
/** jikip 用户 ID(余量查询参数)。 */
|
||||
private String jikipUserId = "";
|
||||
|
||||
/**
|
||||
* 巡检发现欠费/密钥失效时是否推送站内通知(桌面端用户 + 后台管理员);
|
||||
* 关闭后巡检只更新检测状态、不发通知(应急降噪开关)。
|
||||
*/
|
||||
private boolean notifyEnabled = true;
|
||||
}
|
||||
|
||||
+8
-12
@@ -2,6 +2,7 @@ package com.nanri.aiimage.modules.admin.support;
|
||||
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.auth.service.JwtService;
|
||||
import com.nanri.aiimage.modules.auth.support.DeviceSessionPolicy;
|
||||
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
|
||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||
import io.jsonwebtoken.Claims;
|
||||
@@ -52,6 +53,12 @@ public class AdminAuthSupport {
|
||||
if (user == null) {
|
||||
throw new BusinessException(401, "用户不存在");
|
||||
}
|
||||
// 单设备登录:被新设备顶下线的旧 token 在此统一拦截(全站 requireUser 调用点自动生效)
|
||||
if (authProperties.isSingleDeviceEnabled()) {
|
||||
DeviceSessionPolicy.assertSameDevice(user.getMachine(), DeviceSessionPolicy.claimDeviceId(claims),
|
||||
DeviceSessionPolicy.isSuperAdmin(user.getRole(), user.getIsAdmin(), user.getCreatedById()),
|
||||
user.getId(), user.getUsername());
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -70,18 +77,7 @@ public class AdminAuthSupport {
|
||||
if (user == null) {
|
||||
return null;
|
||||
}
|
||||
String storedRole = user.getRole() == null ? "" : user.getRole().trim().toLowerCase();
|
||||
if ("super_admin".equals(storedRole)) {
|
||||
return "super_admin";
|
||||
}
|
||||
if ("admin".equals(storedRole)) {
|
||||
return "admin";
|
||||
}
|
||||
boolean isAdminFlag = user.getIsAdmin() != null && user.getIsAdmin() == 1;
|
||||
if (storedRole.isEmpty() && isAdminFlag) {
|
||||
return user.getCreatedById() == null ? "super_admin" : "admin";
|
||||
}
|
||||
return null;
|
||||
return DeviceSessionPolicy.resolveRole(user.getRole(), user.getIsAdmin(), user.getCreatedById());
|
||||
}
|
||||
|
||||
/** JWT 优先;无 JWT 时以可信内部代理身份(X-Internal-Token + operatorId)回退,仍要求管理员角色。 */
|
||||
|
||||
@@ -14,4 +14,6 @@ public class AuthProperties {
|
||||
private String cookieName = "aiimage_token";
|
||||
private boolean cookieSecure = false;
|
||||
private String cookieSameSite = "Lax";
|
||||
/** 单设备登录(互踢)总开关:关闭后恢复为多设备同时在线(回滚用)。 */
|
||||
private boolean singleDeviceEnabled = true;
|
||||
}
|
||||
|
||||
+16
-45
@@ -8,6 +8,7 @@ import com.nanri.aiimage.modules.auth.mapper.LoginUserMapper;
|
||||
import com.nanri.aiimage.modules.auth.model.dto.LoginRequest;
|
||||
import com.nanri.aiimage.modules.auth.model.entity.LoginUserEntity;
|
||||
import com.nanri.aiimage.modules.auth.model.vo.LoginResultVo;
|
||||
import com.nanri.aiimage.modules.auth.support.DeviceSessionPolicy;
|
||||
import com.nanri.aiimage.modules.auth.util.WerkzeugPasswordEncoder;
|
||||
import com.nanri.aiimage.modules.permission.service.PermissionMenuService;
|
||||
import io.jsonwebtoken.Claims;
|
||||
@@ -48,32 +49,14 @@ public class AuthService {
|
||||
}
|
||||
|
||||
boolean isAdmin = user.getIsAdmin() != null && user.getIsAdmin() == 1;
|
||||
// ---- 设备绑定逻辑临时停用(便于多设备 / 浏览器联调)----
|
||||
// 原逻辑:首次登录写入 machine;设备指纹变化时把 machine 重绑到当前设备。
|
||||
// 注释期间登录不写、不校验 machine,任何设备均可登录,仅保留日志便于排查。
|
||||
/*
|
||||
String stored = user.getMachine() == null ? "" : user.getMachine().trim();
|
||||
if (stored.isEmpty()) {
|
||||
// 单设备登录:登录成功即把账号绑定到当前设备(last-login-wins),原设备下一次请求被顶下线
|
||||
if (authProperties.isSingleDeviceEnabled()) {
|
||||
DeviceSessionPolicy.logBindOnLogin(user.getMachine(), deviceId, user.getId(), user.getUsername());
|
||||
loginUserMapper.update(null, new LambdaUpdateWrapper<LoginUserEntity>()
|
||||
.eq(LoginUserEntity::getId, user.getId())
|
||||
.set(LoginUserEntity::getMachine, deviceId));
|
||||
stored = deviceId;
|
||||
log.info("[auth] first-login bind userId={} device={}", user.getId(), deviceId);
|
||||
} else if (!stored.equals(deviceId)) {
|
||||
// 设备指纹变化(换电脑/重装/清理注册表)会锁死账号,密码已验证通过,
|
||||
// 直接重新绑定到当前设备,避免账号被锁、数据因重建账号而丢失。
|
||||
loginUserMapper.update(null, new LambdaUpdateWrapper<LoginUserEntity>()
|
||||
.eq(LoginUserEntity::getId, user.getId())
|
||||
.set(LoginUserEntity::getMachine, deviceId));
|
||||
log.warn("[auth] device rebound on login userId={} old={} new={}",
|
||||
user.getId(), stored, deviceId);
|
||||
} else {
|
||||
log.info("[auth] device match userId={} isAdmin={} device={}",
|
||||
user.getId(), isAdmin, deviceId);
|
||||
}
|
||||
*/
|
||||
log.info("[auth] login (device-bind disabled) userId={} isAdmin={} device={}",
|
||||
user.getId(), isAdmin, deviceId);
|
||||
log.info("[auth] login userId={} isAdmin={} device={}", user.getId(), isAdmin, deviceId);
|
||||
|
||||
return buildResult(user, deviceId, isAdmin);
|
||||
}
|
||||
@@ -97,31 +80,19 @@ public class AuthService {
|
||||
throw new BusinessException(401, "用户不存在");
|
||||
}
|
||||
boolean isAdmin = user.getIsAdmin() != null && user.getIsAdmin() == 1;
|
||||
String stored = user.getMachine() == null ? "" : user.getMachine().trim();
|
||||
String device = trim(currentDeviceId);
|
||||
if (device.isEmpty()) {
|
||||
// 没传设备 ID 时,回落到 token 内 deviceId
|
||||
Object claimDevice = claims.get("deviceId");
|
||||
device = claimDevice == null ? "" : claimDevice.toString().trim();
|
||||
String claimDeviceId = DeviceSessionPolicy.claimDeviceId(claims);
|
||||
// 单设备登录:不拦截的话,被顶下线的旧 token 会在这里续期「复活」
|
||||
if (authProperties.isSingleDeviceEnabled()) {
|
||||
DeviceSessionPolicy.assertSameDevice(user.getMachine(), claimDeviceId,
|
||||
DeviceSessionPolicy.isSuperAdmin(user.getRole(), user.getIsAdmin(), user.getCreatedById()),
|
||||
user.getId(), user.getUsername());
|
||||
}
|
||||
// ---- 设备绑定逻辑临时停用:设备不一致不再拒绝登录态,仅保留告警日志 ----
|
||||
/*
|
||||
if (!stored.isEmpty() && !device.isEmpty() && !stored.equals(device)) {
|
||||
if (isAdmin) {
|
||||
log.warn("[auth] check_login device mismatch but admin bypass userId={} stored={} current={}",
|
||||
user.getId(), stored, device);
|
||||
} else {
|
||||
log.warn("[auth] check_login device mismatch reject userId={} stored={} current={}",
|
||||
user.getId(), stored, device);
|
||||
throw new BusinessException(401, "当前设备与首次登录设备不一致");
|
||||
String headerDevice = trim(currentDeviceId);
|
||||
if (!headerDevice.isEmpty() && !claimDeviceId.isEmpty() && !headerDevice.equals(claimDeviceId)) {
|
||||
log.warn("[auth] check_login 请求头设备与登录态内设备不一致(以登录态为准)userId={} headerDevice={} loginDevice={}",
|
||||
user.getId(), headerDevice, claimDeviceId);
|
||||
}
|
||||
}
|
||||
*/
|
||||
if (!stored.isEmpty() && !device.isEmpty() && !stored.equals(device)) {
|
||||
log.warn("[auth] check_login device mismatch (bypass) userId={} stored={} current={}",
|
||||
user.getId(), stored, device);
|
||||
}
|
||||
return buildResult(user, stored.isEmpty() ? device : stored, isAdmin);
|
||||
return buildResult(user, claimDeviceId, isAdmin);
|
||||
}
|
||||
|
||||
public ResponseCookie buildAuthCookie(String token) {
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package com.nanri.aiimage.modules.auth.support;
|
||||
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* 单设备登录(互踢)策略。
|
||||
*
|
||||
* <p>账号当前绑定的设备存放在 users.machine(登录成功即覆盖,last-login-wins);
|
||||
* 非超管账号仅允许「token 内签名的 deviceId」与绑定设备一致的请求通过,
|
||||
* 被新设备顶下线的旧 token 在下一次受保护请求时抛 4011。</p>
|
||||
*
|
||||
* <p>校验只认 token 内签名的 deviceId,绝不使用 X-Device-Id 请求头(头是客户端可控的)。</p>
|
||||
*/
|
||||
@Slf4j
|
||||
public final class DeviceSessionPolicy {
|
||||
|
||||
/** 账号已在其他设备登录(前端据此提示并下线本设备)。 */
|
||||
public static final int CODE_KICKED = 4011;
|
||||
|
||||
private DeviceSessionPolicy() {
|
||||
}
|
||||
|
||||
/** 计算用户管理角色:super_admin / admin / null(含老数据 role 为空时按 created_by_id 推断)。 */
|
||||
public static String resolveRole(String role, Integer isAdmin, Long createdById) {
|
||||
String storedRole = role == null ? "" : role.trim().toLowerCase();
|
||||
if ("super_admin".equals(storedRole)) {
|
||||
return "super_admin";
|
||||
}
|
||||
if ("admin".equals(storedRole)) {
|
||||
return "admin";
|
||||
}
|
||||
boolean isAdminFlag = isAdmin != null && isAdmin == 1;
|
||||
if (storedRole.isEmpty() && isAdminFlag) {
|
||||
return createdById == null ? "super_admin" : "admin";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 是否超级管理员(互踢的唯一豁免角色)。 */
|
||||
public static boolean isSuperAdmin(String role, Integer isAdmin, Long createdById) {
|
||||
return "super_admin".equals(resolveRole(role, isAdmin, createdById));
|
||||
}
|
||||
|
||||
/** 从 JWT claims 中提取签名的设备号;缺失返回空串。 */
|
||||
public static String claimDeviceId(Claims claims) {
|
||||
Object raw = claims == null ? null : claims.get("deviceId");
|
||||
return raw == null ? "" : raw.toString().trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验请求携带的 token 是否仍属于账号当前绑定的设备。
|
||||
* 超管豁免;machine 为空(尚未绑定)放行;不匹配抛 4011。
|
||||
*/
|
||||
public static void assertSameDevice(String storedMachine, String claimDeviceId, boolean exempt,
|
||||
Long userId, String username) {
|
||||
if (exempt) {
|
||||
return;
|
||||
}
|
||||
String bound = storedMachine == null ? "" : storedMachine.trim();
|
||||
if (bound.isEmpty()) {
|
||||
// 尚未绑定(首次登录前 / V118 清空后首个登录前),不做限制
|
||||
return;
|
||||
}
|
||||
String claimed = claimDeviceId == null ? "" : claimDeviceId.trim();
|
||||
if (claimed.isEmpty()) {
|
||||
log.warn("[auth] 单设备登录校验:token 缺少设备标识,拒绝 userId={} username={}", userId, username);
|
||||
throw new BusinessException(401, "登录态无效");
|
||||
}
|
||||
if (!bound.equals(claimed)) {
|
||||
log.warn("[auth] 单设备登录拦截:用户 {} 已被其他设备顶下线,token设备={} 当前绑定设备={}",
|
||||
userId, claimed, bound);
|
||||
throw new BusinessException(CODE_KICKED, "该账号已在其他设备登录,本设备已下线");
|
||||
}
|
||||
}
|
||||
|
||||
/** 登录绑定时的中文日志:首次绑定 / 换设备(顶下线)/ 同设备重登,便于线上排查互踢来源。 */
|
||||
public static void logBindOnLogin(String previousMachine, String deviceId, Long userId, String username) {
|
||||
String previous = previousMachine == null ? "" : previousMachine.trim();
|
||||
if (previous.isEmpty()) {
|
||||
log.info("[auth] 单设备登录:用户 {}({})首次绑定设备 {}", userId, username, deviceId);
|
||||
} else if (!previous.equals(deviceId)) {
|
||||
log.warn("[auth] 单设备登录:用户 {}({})在设备 {} 登录,原设备 {} 已被顶下线",
|
||||
userId, username, deviceId, previous);
|
||||
} else {
|
||||
log.info("[auth] 单设备登录:用户 {}({})同设备重新登录 device={}", userId, username, deviceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package com.nanri.aiimage.modules.notification.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
||||
import com.nanri.aiimage.modules.notification.model.vo.NotificationPageVo;
|
||||
import com.nanri.aiimage.modules.notification.model.vo.NotificationSummaryVo;
|
||||
import com.nanri.aiimage.modules.notification.service.NotificationService;
|
||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 后台站内通知(铃铛):超管与管理员共用,按登录管理员维度读写 audience=admin 的通知;
|
||||
* 可见范围(全量 or 分组内成员)在通知生成时已按数据权限过滤。
|
||||
*/
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/admin/notifications")
|
||||
@Tag(name = "站内通知(后台)", description = "管理端铃铛未读数、通知列表与已读标记。")
|
||||
public class AdminNotificationController {
|
||||
|
||||
private final NotificationService notificationService;
|
||||
private final AdminAuthSupport adminAuthSupport;
|
||||
|
||||
@GetMapping("/summary")
|
||||
@Operation(summary = "未读数与最新通知 id")
|
||||
public ApiResponse<NotificationSummaryVo> summary(HttpServletRequest request) {
|
||||
Long userId = currentAdminId(request);
|
||||
return ApiResponse.success(notificationService.summary(userId, NotificationService.AUDIENCE_ADMIN));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "通知分页列表")
|
||||
public ApiResponse<NotificationPageVo> page(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page,
|
||||
@Parameter(description = "每页数量") @RequestParam(defaultValue = "20") Long pageSize,
|
||||
@Parameter(description = "只看未读") @RequestParam(required = false, defaultValue = "false") Boolean onlyUnread) {
|
||||
Long userId = currentAdminId(request);
|
||||
return ApiResponse.success(notificationService.page(
|
||||
userId, NotificationService.AUDIENCE_ADMIN, page, pageSize, Boolean.TRUE.equals(onlyUnread)));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/read")
|
||||
@Operation(summary = "标记单条已读")
|
||||
public ApiResponse<Boolean> markRead(HttpServletRequest request,
|
||||
@Parameter(description = "通知ID", required = true) @PathVariable Long id) {
|
||||
Long userId = currentAdminId(request);
|
||||
return ApiResponse.success("已标记已读",
|
||||
notificationService.markRead(userId, NotificationService.AUDIENCE_ADMIN, id));
|
||||
}
|
||||
|
||||
@PostMapping("/read-all")
|
||||
@Operation(summary = "全部标记已读")
|
||||
public ApiResponse<Integer> markAllRead(HttpServletRequest request) {
|
||||
Long userId = currentAdminId(request);
|
||||
int updated = notificationService.markAllRead(userId, NotificationService.AUDIENCE_ADMIN);
|
||||
return ApiResponse.success("已全部标记已读", updated);
|
||||
}
|
||||
|
||||
private Long currentAdminId(HttpServletRequest request) {
|
||||
AdminUserEntity operator = adminAuthSupport.requireAdmin(request);
|
||||
return operator.getId();
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package com.nanri.aiimage.modules.notification.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
||||
import com.nanri.aiimage.modules.notification.model.vo.NotificationPageVo;
|
||||
import com.nanri.aiimage.modules.notification.model.vo.NotificationSummaryVo;
|
||||
import com.nanri.aiimage.modules.notification.service.NotificationService;
|
||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 桌面端站内通知(铃铛):当前登录用户维度,用户身份一律从 JWT 解析;
|
||||
* 只读写 audience=user 的通知,不感知后台管理员通知。
|
||||
*/
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/notifications")
|
||||
@Tag(name = "站内通知(桌面端)", description = "铃铛未读数、通知列表与已读标记。")
|
||||
public class NotificationController {
|
||||
|
||||
private final NotificationService notificationService;
|
||||
private final AdminAuthSupport adminAuthSupport;
|
||||
|
||||
@GetMapping("/summary")
|
||||
@Operation(summary = "未读数与最新通知 id", description = "桌面端铃铛轮询用;latestId 增大表示有新通知。")
|
||||
public ApiResponse<NotificationSummaryVo> summary(HttpServletRequest request) {
|
||||
Long userId = currentUserId(request);
|
||||
return ApiResponse.success(notificationService.summary(userId, NotificationService.AUDIENCE_USER));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "通知分页列表", description = "onlyUnread=true 时只返回未读;附带未读总数。")
|
||||
public ApiResponse<NotificationPageVo> page(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page,
|
||||
@Parameter(description = "每页数量") @RequestParam(defaultValue = "20") Long pageSize,
|
||||
@Parameter(description = "只看未读") @RequestParam(required = false, defaultValue = "false") Boolean onlyUnread) {
|
||||
Long userId = currentUserId(request);
|
||||
return ApiResponse.success(notificationService.page(
|
||||
userId, NotificationService.AUDIENCE_USER, page, pageSize, Boolean.TRUE.equals(onlyUnread)));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/read")
|
||||
@Operation(summary = "标记单条已读")
|
||||
public ApiResponse<Boolean> markRead(HttpServletRequest request,
|
||||
@Parameter(description = "通知ID", required = true) @PathVariable Long id) {
|
||||
Long userId = currentUserId(request);
|
||||
return ApiResponse.success("已标记已读",
|
||||
notificationService.markRead(userId, NotificationService.AUDIENCE_USER, id));
|
||||
}
|
||||
|
||||
@PostMapping("/read-all")
|
||||
@Operation(summary = "全部标记已读")
|
||||
public ApiResponse<Integer> markAllRead(HttpServletRequest request) {
|
||||
Long userId = currentUserId(request);
|
||||
int updated = notificationService.markAllRead(userId, NotificationService.AUDIENCE_USER);
|
||||
return ApiResponse.success("已全部标记已读", updated);
|
||||
}
|
||||
|
||||
private Long currentUserId(HttpServletRequest request) {
|
||||
AdminUserEntity me = adminAuthSupport.requireUser(request);
|
||||
return me.getId();
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.nanri.aiimage.modules.notification.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.nanri.aiimage.modules.notification.model.entity.UserNotificationEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface UserNotificationMapper extends BaseMapper<UserNotificationEntity> {
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.nanri.aiimage.modules.notification.model.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("biz_user_notification")
|
||||
public class UserNotificationEntity {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private String audience;
|
||||
private String scene;
|
||||
private String level;
|
||||
private String title;
|
||||
private String content;
|
||||
private String dedupeKey;
|
||||
private LocalDateTime readAt;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.nanri.aiimage.modules.notification.model.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 单条通知(桌面端与后台共用结构)。 */
|
||||
@Data
|
||||
public class NotificationItemVo {
|
||||
|
||||
private Long id;
|
||||
/** 场景:secret_balance/secret_invalid/task_failed/service_down/system */
|
||||
private String scene;
|
||||
/** 级别:info/warning/error */
|
||||
private String level;
|
||||
private String title;
|
||||
private String content;
|
||||
/** 是否已读(前端展示用,等价于 readAt 非空) */
|
||||
private Boolean read;
|
||||
private LocalDateTime readAt;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.nanri.aiimage.modules.notification.model.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 通知分页结果(含未读总数,供铃铛徽标刷新)。 */
|
||||
@Data
|
||||
public class NotificationPageVo {
|
||||
|
||||
private List<NotificationItemVo> items;
|
||||
private Long total;
|
||||
private Long page;
|
||||
private Long pageSize;
|
||||
private Long unreadCount;
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.nanri.aiimage.modules.notification.model.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** 通知摘要:未读数 + 最新通知 id(前端轮询用,发现 latestId 增大即弹提醒)。 */
|
||||
@Data
|
||||
public class NotificationSummaryVo {
|
||||
|
||||
private Long unreadCount;
|
||||
private Long latestId;
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package com.nanri.aiimage.modules.notification.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
||||
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
|
||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||
import com.nanri.aiimage.modules.permission.support.UserDataScopeSupport;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 通知分发:按受众把通知落到具体接收者。
|
||||
* - 桌面端用户:直接推送该用户(audience=user);
|
||||
* - 后台管理员:全部超管 + 管理员,subjectUserId 非空时按数据权限过滤
|
||||
* (超管全量;管理员仅看自己管辖分组内用户的事件,与后台密钥管理页一致)。
|
||||
* 批量场景(任务失败扫描)先用 {@link #prepareAdminAudience()} 解析一次受众,循环内复用避免重复查库。
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class NotificationDispatchService {
|
||||
|
||||
private static final int ADMIN_LIMIT = 500;
|
||||
|
||||
private final NotificationService notificationService;
|
||||
private final AdminUserMapper adminUserMapper;
|
||||
private final AdminAuthSupport adminAuthSupport;
|
||||
private final UserDataScopeSupport userDataScopeSupport;
|
||||
|
||||
/** 推给某个桌面端用户。 */
|
||||
public boolean pushToUser(Long userId, String scene, String level, String title, String content, String dedupeKey) {
|
||||
return notificationService.pushOrRefresh(userId, NotificationService.AUDIENCE_USER, scene, level, title, content, dedupeKey);
|
||||
}
|
||||
|
||||
/** 单次推送管理员(内部解析一次受众;扫描 job 批量场景请用 prepareAdminAudience 复用)。 */
|
||||
public int pushToAdmins(String scene, String level, String title, String content,
|
||||
String dedupeKeyBase, Long subjectUserId) {
|
||||
return pushToAdmins(prepareAdminAudience(), scene, level, title, content, dedupeKeyBase, subjectUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送管理员(复用已解析受众)。
|
||||
*
|
||||
* @param subjectUserId 事件主体用户;非空时按数据权限过滤,null 表示全局事件(如服务异常)所有管理员可见
|
||||
* @return 实际落库条数
|
||||
*/
|
||||
public int pushToAdmins(AdminAudience audience, String scene, String level, String title, String content,
|
||||
String dedupeKeyBase, Long subjectUserId) {
|
||||
int pushed = 0;
|
||||
int filtered = 0;
|
||||
for (AdminUserEntity admin : audience.admins()) {
|
||||
if (!audience.canReceive(admin.getId(), subjectUserId)) {
|
||||
filtered++;
|
||||
continue;
|
||||
}
|
||||
String key = dedupeKeyBase + ":" + admin.getId();
|
||||
if (notificationService.pushOrRefresh(admin.getId(), NotificationService.AUDIENCE_ADMIN,
|
||||
scene, level, title, content, key)) {
|
||||
pushed++;
|
||||
}
|
||||
}
|
||||
log.info("[notification] 管理员分发完成 scene={} subjectUserId={} 受众={} 权限过滤={} 落库={}",
|
||||
scene, subjectUserId, audience.admins().size(), filtered, pushed);
|
||||
return pushed;
|
||||
}
|
||||
|
||||
/** 解析管理员受众:全部超管 + 管理员(非管理员角色一律排除),并预取各管理员可见用户集。 */
|
||||
public AdminAudience prepareAdminAudience() {
|
||||
List<AdminUserEntity> candidates = adminUserMapper.selectList(new LambdaQueryWrapper<AdminUserEntity>()
|
||||
.eq(AdminUserEntity::getIsAdmin, 1)
|
||||
.last("limit " + ADMIN_LIMIT));
|
||||
List<AdminUserEntity> admins = new ArrayList<>();
|
||||
Set<Long> superAdminIds = new HashSet<>();
|
||||
Map<Long, Set<Long>> visibleByAdmin = new HashMap<>();
|
||||
for (AdminUserEntity candidate : candidates) {
|
||||
String role = adminAuthSupport.currentRole(candidate);
|
||||
if (role == null) {
|
||||
continue;
|
||||
}
|
||||
admins.add(candidate);
|
||||
if ("super_admin".equals(role)) {
|
||||
superAdminIds.add(candidate.getId());
|
||||
} else {
|
||||
visibleByAdmin.put(candidate.getId(), new HashSet<>(userDataScopeSupport.resolveVisibleUserIds(candidate.getId())));
|
||||
}
|
||||
}
|
||||
log.info("[notification] 管理员受众解析完成 管理员数={} 超管={} 需按分组过滤={}",
|
||||
admins.size(), superAdminIds.size(), visibleByAdmin.size());
|
||||
return new AdminAudience(admins, superAdminIds, visibleByAdmin);
|
||||
}
|
||||
|
||||
/** 用户名(通知文案用):查不到时回退「用户#id」。 */
|
||||
public String displayNameOf(Long userId) {
|
||||
if (userId == null) {
|
||||
return "未知用户";
|
||||
}
|
||||
AdminUserEntity user = adminUserMapper.selectById(userId);
|
||||
String username = user == null ? null : user.getUsername();
|
||||
if (username == null || username.isBlank()) {
|
||||
return "用户#" + userId;
|
||||
}
|
||||
return username;
|
||||
}
|
||||
|
||||
/** 一次解析好的管理员受众(admins + 超管集合 + 各管理员可见用户集),可跨多条事件复用。 */
|
||||
public record AdminAudience(List<AdminUserEntity> admins,
|
||||
Set<Long> superAdminIds,
|
||||
Map<Long, Set<Long>> visibleByAdmin) {
|
||||
|
||||
/** 该管理员是否可接收主体用户为 subjectUserId 的事件;subjectUserId=null 为全局事件。 */
|
||||
public boolean canReceive(Long adminId, Long subjectUserId) {
|
||||
if (subjectUserId == null || superAdminIds.contains(adminId)) {
|
||||
return true;
|
||||
}
|
||||
Set<Long> visible = visibleByAdmin.get(adminId);
|
||||
return visible != null && visible.contains(subjectUserId);
|
||||
}
|
||||
}
|
||||
}
|
||||
+374
@@ -0,0 +1,374 @@
|
||||
package com.nanri.aiimage.modules.notification.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.nanri.aiimage.config.HttpClientPool;
|
||||
import com.nanri.aiimage.config.NotificationProperties;
|
||||
import com.nanri.aiimage.common.service.DistributedJobLockService;
|
||||
import com.nanri.aiimage.modules.brand.mapper.BrandCrawlTaskMapper;
|
||||
import com.nanri.aiimage.modules.brand.model.entity.BrandCrawlTaskEntity;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||
import com.nanri.aiimage.modules.usersecret.client.JikipProxyClient;
|
||||
import com.nanri.aiimage.modules.usersecret.model.vo.UserApiSecretBalanceVo;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 站内通知扫描:任务失败聚合提醒 + 下游服务健康探测。
|
||||
*
|
||||
* <p>任务失败:回看窗口内进入失败终态的任务(biz_file_task / brand_crawl_tasks),
|
||||
* 按「用户 × 模块 × 小时」聚合,同一小时桶内增量刷新同一条通知(不刷屏);
|
||||
* 用户侧推给任务创建者,管理员侧按数据权限推给管辖该用户的管理员。
|
||||
*
|
||||
* <p>服务探测:品牌检测服务(15126) / 跟价任务 API(18960) / jikip 代理接口,
|
||||
* 失败立即重试一次(过滤瞬抖),两次都失败才告警;同服务每小时最多一条。
|
||||
*
|
||||
* <p>双实例经 Redis 分布式锁互斥;所有分支留中文日志便于线上排查。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class NotificationScanScheduler {
|
||||
|
||||
private static final String LOCK_NAME = "notification-scan";
|
||||
|
||||
private static final String STATUS_FAILED = "FAILED";
|
||||
private static final String BRAND_STATUS_FAILED = "failed";
|
||||
private static final String BRAND_MODULE_TYPE = "BRAND";
|
||||
|
||||
private static final int MAX_TASK_NO_PREVIEW = 5;
|
||||
private static final int REASON_MAX_LENGTH = 120;
|
||||
private static final int PROBE_READ_TIMEOUT_MILLIS = 8_000;
|
||||
|
||||
private static final DateTimeFormatter HOUR_FORMAT = DateTimeFormatter.ofPattern("yyyyMMddHH");
|
||||
|
||||
/** 任务模块类型 → 中文名(与 TaskHeartbeatService 的模块常量对齐)。 */
|
||||
private static final Map<String, String> MODULE_LABELS = Map.ofEntries(
|
||||
Map.entry("APPEARANCE_PATENT", "外观专利检测"),
|
||||
Map.entry("SIMILAR_ASIN", "货源查询"),
|
||||
Map.entry("PATROL_DELETE", "巡店删除"),
|
||||
Map.entry("PRICE_TRACK", "跟价"),
|
||||
Map.entry("PRODUCT_RISK_RESOLVE", "商品风险解决"),
|
||||
Map.entry("QUERY_ASIN", "查询ASIN"),
|
||||
Map.entry("WITHDRAW", "取款"),
|
||||
Map.entry("SHOP_DATA_CRAWL", "店铺数据抓取"),
|
||||
Map.entry("SHOP_MATCH", "定时匹配"),
|
||||
Map.entry("BRAND", "品牌检测"),
|
||||
Map.entry("COLLECT_DATA", "采集数据"),
|
||||
Map.entry("DELETE_BRAND", "删除ASIN"),
|
||||
Map.entry("SPLIT", "数据拆分"),
|
||||
Map.entry("CONVERT", "格式转换"),
|
||||
Map.entry("PUBLISH", "上架"),
|
||||
Map.entry("DEDUPE", "数据去重"));
|
||||
|
||||
private final FileTaskMapper fileTaskMapper;
|
||||
private final BrandCrawlTaskMapper brandCrawlTaskMapper;
|
||||
private final NotificationService notificationService;
|
||||
private final NotificationDispatchService notificationDispatchService;
|
||||
private final DistributedJobLockService distributedJobLockService;
|
||||
private final NotificationProperties properties;
|
||||
private final JikipProxyClient jikipProxyClient;
|
||||
|
||||
/** 上次清理日期(每天最多清理一次;双节点由分布式锁保证只有一个实例执行)。 */
|
||||
private volatile LocalDate lastCleanupDate;
|
||||
|
||||
@Scheduled(fixedDelayString = "${aiimage.notification.scan-interval-ms:300000}",
|
||||
initialDelayString = "${aiimage.notification.scan-initial-delay-ms:120000}")
|
||||
public void scan() {
|
||||
if (!properties.isScanEnabled()) {
|
||||
log.info("[notification-scan] 扫描已关闭(scan-enabled=false),跳过本轮");
|
||||
return;
|
||||
}
|
||||
var lock = distributedJobLockService.tryLock(LOCK_NAME, Duration.ofMinutes(5));
|
||||
if (lock == null) {
|
||||
log.info("[notification-scan] 另一实例持有扫描锁,跳过本轮");
|
||||
return;
|
||||
}
|
||||
try (lock) {
|
||||
if (properties.isTaskScanEnabled()) {
|
||||
scanFailedTasks();
|
||||
}
|
||||
if (properties.isServiceProbeEnabled()) {
|
||||
probeServices();
|
||||
}
|
||||
cleanupExpiredIfNeeded();
|
||||
} catch (Exception ex) {
|
||||
log.warn("[notification-scan] 扫描异常终止 err={}", ex.getMessage(), ex);
|
||||
}
|
||||
}
|
||||
|
||||
/** 任务失败聚合扫描:窗口内失败终态任务按「用户 × 模块 × 小时」聚合成一条通知。 */
|
||||
void scanFailedTasks() {
|
||||
LocalDateTime cutoff = LocalDateTime.now()
|
||||
.minusMinutes(Math.max(1, properties.getTaskFailedWindowMinutes()));
|
||||
int limit = Math.max(1, properties.getTaskScanMaxRows());
|
||||
|
||||
List<FailedTask> failed = new ArrayList<>();
|
||||
List<FileTaskEntity> fileTasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
|
||||
.select(FileTaskEntity::getId, FileTaskEntity::getTaskNo, FileTaskEntity::getModuleType,
|
||||
FileTaskEntity::getUserId, FileTaskEntity::getErrorMessage, FileTaskEntity::getUpdatedAt)
|
||||
.eq(FileTaskEntity::getStatus, STATUS_FAILED)
|
||||
.gt(FileTaskEntity::getUpdatedAt, cutoff)
|
||||
.orderByDesc(FileTaskEntity::getId)
|
||||
.last("limit " + limit));
|
||||
for (FileTaskEntity task : fileTasks) {
|
||||
if (task.getUserId() == null || task.getUserId() <= 0) {
|
||||
continue;
|
||||
}
|
||||
failed.add(new FailedTask(task.getId(), displayTaskNo(task.getTaskNo(), task.getId()),
|
||||
task.getModuleType(), task.getUserId(), task.getErrorMessage()));
|
||||
}
|
||||
List<BrandCrawlTaskEntity> brandTasks = brandCrawlTaskMapper.selectList(new LambdaQueryWrapper<BrandCrawlTaskEntity>()
|
||||
.select(BrandCrawlTaskEntity::getId, BrandCrawlTaskEntity::getUserId,
|
||||
BrandCrawlTaskEntity::getErrorMessage, BrandCrawlTaskEntity::getUpdatedAt)
|
||||
.eq(BrandCrawlTaskEntity::getStatus, BRAND_STATUS_FAILED)
|
||||
.gt(BrandCrawlTaskEntity::getUpdatedAt, cutoff)
|
||||
.orderByDesc(BrandCrawlTaskEntity::getId)
|
||||
.last("limit " + limit));
|
||||
for (BrandCrawlTaskEntity task : brandTasks) {
|
||||
if (task.getUserId() == null || task.getUserId() <= 0) {
|
||||
continue;
|
||||
}
|
||||
failed.add(new FailedTask(task.getId(), "#" + task.getId(),
|
||||
BRAND_MODULE_TYPE, task.getUserId(), task.getErrorMessage()));
|
||||
}
|
||||
if (failed.isEmpty()) {
|
||||
log.info("[notification-scan] 近 {} 分钟无失败任务", properties.getTaskFailedWindowMinutes());
|
||||
return;
|
||||
}
|
||||
|
||||
String hour = LocalDateTime.now().format(HOUR_FORMAT);
|
||||
Map<BucketKey, List<FailedTask>> buckets = new LinkedHashMap<>();
|
||||
for (FailedTask task : failed) {
|
||||
String moduleType = normalizeModuleType(task.moduleType());
|
||||
buckets.computeIfAbsent(new BucketKey(task.userId(), moduleType, hour), key -> new ArrayList<>())
|
||||
.add(task);
|
||||
}
|
||||
|
||||
NotificationDispatchService.AdminAudience audience = notificationDispatchService.prepareAdminAudience();
|
||||
int userPushed = 0;
|
||||
int adminPushed = 0;
|
||||
for (Map.Entry<BucketKey, List<FailedTask>> entry : buckets.entrySet()) {
|
||||
BucketKey key = entry.getKey();
|
||||
List<FailedTask> tasks = entry.getValue();
|
||||
String label = MODULE_LABELS.getOrDefault(key.moduleType(), key.moduleType());
|
||||
String preview = taskNoPreview(tasks);
|
||||
String reason = latestReason(tasks);
|
||||
|
||||
String userContent = "最近 " + properties.getTaskFailedWindowMinutes() + " 分钟内有 " + tasks.size()
|
||||
+ " 个" + label + "任务失败"
|
||||
+ (preview.isEmpty() ? "" : "(" + preview + ")")
|
||||
+ (reason.isEmpty() ? "" : ";最近失败原因:" + reason)
|
||||
+ ",详情请查看对应工具页的「历史任务」。";
|
||||
if (notificationService.pushOrRefresh(key.userId(), NotificationService.AUDIENCE_USER,
|
||||
NotificationService.SCENE_TASK_FAILED, NotificationService.LEVEL_WARNING,
|
||||
label + "任务失败", userContent, dedupeKeyOf(key))) {
|
||||
userPushed++;
|
||||
}
|
||||
|
||||
String username = notificationDispatchService.displayNameOf(key.userId());
|
||||
String adminContent = "用户 " + username + "(uid=" + key.userId() + ")有 " + tasks.size()
|
||||
+ " 个" + label + "任务失败"
|
||||
+ (preview.isEmpty() ? "" : "(" + preview + ")")
|
||||
+ (reason.isEmpty() ? "" : ";最近失败原因:" + reason);
|
||||
adminPushed += notificationDispatchService.pushToAdmins(audience,
|
||||
NotificationService.SCENE_TASK_FAILED, NotificationService.LEVEL_WARNING,
|
||||
"用户任务失败:" + username, adminContent,
|
||||
"task_failed_admin:" + key.userId() + ":" + key.moduleType() + ":" + key.hour(),
|
||||
key.userId());
|
||||
}
|
||||
log.info("[notification-scan] 任务失败扫描完成 窗口={}分钟 失败任务={} 聚合桶={} 用户通知={} 管理员通知={}",
|
||||
properties.getTaskFailedWindowMinutes(), failed.size(), buckets.size(), userPushed, adminPushed);
|
||||
}
|
||||
|
||||
/** 下游服务健康探测:品牌检测服务 / 跟价任务 API / jikip 代理接口。 */
|
||||
void probeServices() {
|
||||
if (hasText(properties.getBrandServiceUrl())) {
|
||||
reportProbe("brand-service", "品牌检测服务",
|
||||
probeHttpWithRetry(joinUrl(properties.getBrandServiceUrl(), "/api/version")));
|
||||
} else {
|
||||
log.info("[notification-scan] 品牌检测服务探测地址未配置,跳过");
|
||||
}
|
||||
if (hasText(properties.getPriceTrackApiUrl())) {
|
||||
reportProbe("price-track-api", "跟价任务 API",
|
||||
probeHttpWithRetry(joinUrl(properties.getPriceTrackApiUrl(), "/")));
|
||||
} else {
|
||||
log.info("[notification-scan] 跟价任务 API 探测地址未配置,跳过");
|
||||
}
|
||||
if (properties.isJikipProbeEnabled()) {
|
||||
reportProbe("jikip", "jikip 代理接口", probeJikip());
|
||||
}
|
||||
}
|
||||
|
||||
/** HTTP 探测:失败立即重试一次,两次都失败才算故障(过滤瞬时抖动)。 */
|
||||
private ProbeOutcome probeHttpWithRetry(String url) {
|
||||
ProbeOutcome first = probeHttpOnce(url);
|
||||
if (first.ok()) {
|
||||
return first;
|
||||
}
|
||||
log.warn("[notification-scan] 服务探测第一次失败,重试一次 url={} detail={}", url, first.detail());
|
||||
return probeHttpOnce(url);
|
||||
}
|
||||
|
||||
private ProbeOutcome probeHttpOnce(String url) {
|
||||
long startMillis = System.currentTimeMillis();
|
||||
try {
|
||||
Integer status = RestClient.builder()
|
||||
.requestFactory(HttpClientPool.requestFactory(PROBE_READ_TIMEOUT_MILLIS))
|
||||
.build()
|
||||
.get()
|
||||
.uri(url)
|
||||
.exchange((request, response) -> response.getStatusCode().value());
|
||||
long latency = System.currentTimeMillis() - startMillis;
|
||||
// 任意 HTTP < 500 视为存活(4xx 说明服务在线,仅路径/鉴权问题)
|
||||
if (status != null && status < 500) {
|
||||
return new ProbeOutcome(true, "HTTP " + status + "(" + latency + "ms)");
|
||||
}
|
||||
return new ProbeOutcome(false, "HTTP " + status + "(" + latency + "ms)");
|
||||
} catch (Exception ex) {
|
||||
long latency = System.currentTimeMillis() - startMillis;
|
||||
return new ProbeOutcome(false, rootCauseMessage(ex) + "(" + latency + "ms)");
|
||||
}
|
||||
}
|
||||
|
||||
/** jikip 探测:复用余额查询接口;未配置套餐信息时跳过(返回 null 不告警)。 */
|
||||
private ProbeOutcome probeJikip() {
|
||||
try {
|
||||
UserApiSecretBalanceVo balance = jikipProxyClient.fetchBalance();
|
||||
if (Boolean.TRUE.equals(balance.getAvailable())) {
|
||||
return new ProbeOutcome(true, "余额接口可用 surplus=" + balance.getSurplus()
|
||||
+ " balance=" + balance.getBalance());
|
||||
}
|
||||
String message = balance.getMessage() == null ? "" : balance.getMessage();
|
||||
if (message.contains("未配置")) {
|
||||
log.info("[notification-scan] jikip 探测跳过:{}", message);
|
||||
return null;
|
||||
}
|
||||
return new ProbeOutcome(false, message.isEmpty() ? "余额接口不可用" : message);
|
||||
} catch (Exception ex) {
|
||||
return new ProbeOutcome(false, rootCauseMessage(ex));
|
||||
}
|
||||
}
|
||||
|
||||
/** 探测结果上报:正常记 info;故障按小时去重推管理员通知;outcome=null 表示跳过。 */
|
||||
private void reportProbe(String serviceKey, String serviceLabel, ProbeOutcome outcome) {
|
||||
if (outcome == null) {
|
||||
return;
|
||||
}
|
||||
if (outcome.ok()) {
|
||||
log.info("[notification-scan] 服务探测正常 service={} detail={}", serviceLabel, outcome.detail());
|
||||
return;
|
||||
}
|
||||
log.warn("[notification-scan] 服务探测失败 service={} detail={}", serviceLabel, outcome.detail());
|
||||
String hour = LocalDateTime.now().format(HOUR_FORMAT);
|
||||
notificationDispatchService.pushToAdmins(NotificationService.SCENE_SERVICE_DOWN,
|
||||
NotificationService.LEVEL_ERROR,
|
||||
serviceLabel + "不可用",
|
||||
serviceLabel + "探测失败:" + outcome.detail() + "。该服务相关任务可能大面积失败,请尽快排查。",
|
||||
"service_down:" + serviceKey + ":" + hour, null);
|
||||
}
|
||||
|
||||
/** 已读通知保留期清理:每天最多一次。 */
|
||||
private void cleanupExpiredIfNeeded() {
|
||||
LocalDate today = LocalDate.now();
|
||||
if (today.equals(lastCleanupDate)) {
|
||||
return;
|
||||
}
|
||||
lastCleanupDate = today;
|
||||
LocalDateTime cutoff = LocalDateTime.now().minusDays(Math.max(1, properties.getReadRetentionDays()));
|
||||
notificationService.cleanupReadBefore(cutoff);
|
||||
}
|
||||
|
||||
private String dedupeKeyOf(BucketKey key) {
|
||||
return "task_failed:" + key.userId() + ":" + key.moduleType() + ":" + key.hour();
|
||||
}
|
||||
|
||||
private String taskNoPreview(List<FailedTask> tasks) {
|
||||
List<String> previews = new ArrayList<>(MAX_TASK_NO_PREVIEW);
|
||||
for (FailedTask task : tasks) {
|
||||
if (task.taskNo() == null || task.taskNo().isBlank()) {
|
||||
continue;
|
||||
}
|
||||
previews.add(task.taskNo());
|
||||
if (previews.size() >= MAX_TASK_NO_PREVIEW) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (previews.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
String text = String.join("、", previews);
|
||||
return tasks.size() > previews.size() ? text + " 等" : text;
|
||||
}
|
||||
|
||||
private String latestReason(List<FailedTask> tasks) {
|
||||
for (FailedTask task : tasks) {
|
||||
if (task.errorMessage() != null && !task.errorMessage().isBlank()) {
|
||||
String reason = task.errorMessage().trim().replaceAll("\\s+", " ");
|
||||
return reason.length() <= REASON_MAX_LENGTH ? reason : reason.substring(0, REASON_MAX_LENGTH) + "…";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private String displayTaskNo(String taskNo, Long id) {
|
||||
return taskNo == null || taskNo.isBlank() ? "#" + id : taskNo;
|
||||
}
|
||||
|
||||
private String normalizeModuleType(String moduleType) {
|
||||
String normalized = moduleType == null ? "" : moduleType.trim();
|
||||
if (normalized.isEmpty()) {
|
||||
return "UNKNOWN";
|
||||
}
|
||||
// 历史小写值(legacy collectdata)统一成大写常量
|
||||
return normalized.equalsIgnoreCase("collectdata") ? "COLLECT_DATA" : normalized.toUpperCase();
|
||||
}
|
||||
|
||||
private boolean hasText(String value) {
|
||||
return value != null && !value.trim().isEmpty();
|
||||
}
|
||||
|
||||
private String joinUrl(String baseUrl, String path) {
|
||||
String base = baseUrl == null ? "" : baseUrl.trim();
|
||||
if (base.endsWith("/")) {
|
||||
base = base.substring(0, base.length() - 1);
|
||||
}
|
||||
return base + path;
|
||||
}
|
||||
|
||||
private String rootCauseMessage(Throwable throwable) {
|
||||
Throwable current = throwable;
|
||||
while (current.getCause() != null && current.getCause() != current) {
|
||||
current = current.getCause();
|
||||
}
|
||||
String message = current.getMessage();
|
||||
if (message == null || message.isBlank()) {
|
||||
return current.getClass().getSimpleName();
|
||||
}
|
||||
return current.getClass().getSimpleName() + ": " + message;
|
||||
}
|
||||
|
||||
/** 聚合桶键:用户 × 模块 × 小时。 */
|
||||
record BucketKey(Long userId, String moduleType, String hour) {
|
||||
}
|
||||
|
||||
/** 统一视图的失败任务(两类表归一)。 */
|
||||
record FailedTask(Long id, String taskNo, String moduleType, Long userId, String errorMessage) {
|
||||
}
|
||||
|
||||
/** 探测结果。 */
|
||||
record ProbeOutcome(boolean ok, String detail) {
|
||||
}
|
||||
}
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
package com.nanri.aiimage.modules.notification.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.nanri.aiimage.modules.notification.mapper.UserNotificationMapper;
|
||||
import com.nanri.aiimage.modules.notification.model.entity.UserNotificationEntity;
|
||||
import com.nanri.aiimage.modules.notification.model.vo.NotificationItemVo;
|
||||
import com.nanri.aiimage.modules.notification.model.vo.NotificationPageVo;
|
||||
import com.nanri.aiimage.modules.notification.model.vo.NotificationSummaryVo;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 站内通知存取:桌面端用户(audience=user)与后台管理员(audience=admin)共用一张表,
|
||||
* 按 user_id + audience 隔离;push 前按 dedupe_key 精确查重(键自带时间粒度,见各来源生成规则)。
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class NotificationService {
|
||||
|
||||
public static final String AUDIENCE_USER = "user";
|
||||
public static final String AUDIENCE_ADMIN = "admin";
|
||||
|
||||
public static final String LEVEL_INFO = "info";
|
||||
public static final String LEVEL_WARNING = "warning";
|
||||
public static final String LEVEL_ERROR = "error";
|
||||
|
||||
public static final String SCENE_SECRET_BALANCE = "secret_balance";
|
||||
public static final String SCENE_SECRET_INVALID = "secret_invalid";
|
||||
public static final String SCENE_TASK_FAILED = "task_failed";
|
||||
public static final String SCENE_SERVICE_DOWN = "service_down";
|
||||
public static final String SCENE_SYSTEM = "system";
|
||||
|
||||
private static final long MAX_PAGE_SIZE = 100L;
|
||||
private static final int TITLE_MAX_LENGTH = 128;
|
||||
private static final int CONTENT_MAX_LENGTH = 512;
|
||||
private static final int DEDUPE_KEY_MAX_LENGTH = 160;
|
||||
|
||||
private final UserNotificationMapper userNotificationMapper;
|
||||
|
||||
/**
|
||||
* 推送一条通知。dedupeKey 非空且库中已存在同键记录时跳过(返回 false),
|
||||
* 用于「同一异常在时间窗内只提醒一次」;dedupeKey 为空则不去重。
|
||||
*/
|
||||
public boolean push(Long userId, String audience, String scene, String level,
|
||||
String title, String content, String dedupeKey) {
|
||||
if (userId == null || userId <= 0) {
|
||||
log.warn("[notification] 推送被跳过:接收者无效 userId={} scene={}", userId, scene);
|
||||
return false;
|
||||
}
|
||||
String key = normalize(dedupeKey);
|
||||
if (!key.isEmpty() && existsByDedupeKey(key)) {
|
||||
log.info("[notification] 去重命中,跳过推送 userId={} audience={} scene={} dedupeKey={}",
|
||||
userId, audience, scene, key);
|
||||
return false;
|
||||
}
|
||||
UserNotificationEntity row = new UserNotificationEntity();
|
||||
row.setUserId(userId);
|
||||
row.setAudience(normalize(audience).isEmpty() ? AUDIENCE_USER : audience);
|
||||
row.setScene(normalize(scene).isEmpty() ? SCENE_SYSTEM : scene);
|
||||
row.setLevel(normalize(level).isEmpty() ? LEVEL_WARNING : level);
|
||||
row.setTitle(truncate(title, TITLE_MAX_LENGTH));
|
||||
row.setContent(truncate(content == null ? "" : content, CONTENT_MAX_LENGTH));
|
||||
row.setDedupeKey(truncate(key, DEDUPE_KEY_MAX_LENGTH));
|
||||
row.setCreatedAt(LocalDateTime.now());
|
||||
try {
|
||||
userNotificationMapper.insert(row);
|
||||
} catch (Exception ex) {
|
||||
// 通知失败不阻断业务主流程(巡检/扫描 job 的调用方也不应因此中断)
|
||||
log.warn("[notification] 推送落库失败 userId={} scene={} err={}", userId, scene, ex.getMessage(), ex);
|
||||
return false;
|
||||
}
|
||||
log.info("[notification] 已推送 id={} userId={} audience={} scene={} level={} title={}",
|
||||
row.getId(), userId, row.getAudience(), row.getScene(), row.getLevel(), row.getTitle());
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同键推送或刷新:不存在则新建;已存在且内容有变化时更新内容并重置为未读
|
||||
* (用于小时桶聚合的增量更新:同小时内新增失败任务时刷新计数与明细)。
|
||||
*
|
||||
* @return true=新建或内容有更新(用户有新信息),false=无变化或去重未命中变化
|
||||
*/
|
||||
public boolean pushOrRefresh(Long userId, String audience, String scene, String level,
|
||||
String title, String content, String dedupeKey) {
|
||||
String key = normalize(dedupeKey);
|
||||
if (key.isEmpty()) {
|
||||
return push(userId, audience, scene, level, title, content, key);
|
||||
}
|
||||
UserNotificationEntity existing = selectByDedupeKey(key);
|
||||
if (existing == null) {
|
||||
return push(userId, audience, scene, level, title, content, key);
|
||||
}
|
||||
String nextTitle = truncate(title, TITLE_MAX_LENGTH);
|
||||
String nextContent = truncate(content == null ? "" : content, CONTENT_MAX_LENGTH);
|
||||
if (nextContent.equals(existing.getContent()) && nextTitle.equals(existing.getTitle())) {
|
||||
return false;
|
||||
}
|
||||
userNotificationMapper.update(null, new LambdaUpdateWrapper<UserNotificationEntity>()
|
||||
.eq(UserNotificationEntity::getId, existing.getId())
|
||||
.set(UserNotificationEntity::getTitle, nextTitle)
|
||||
.set(UserNotificationEntity::getContent, nextContent)
|
||||
.set(UserNotificationEntity::getLevel, normalize(level).isEmpty() ? LEVEL_WARNING : level)
|
||||
.set(UserNotificationEntity::getReadAt, null));
|
||||
log.info("[notification] 同键通知已刷新 id={} userId={} scene={} dedupeKey={}",
|
||||
existing.getId(), userId, scene, key);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 分页查询(id 倒序);onlyUnread=true 时只返回未读。 */ public NotificationPageVo page(Long userId, String audience, long page, long pageSize, boolean onlyUnread) {
|
||||
long safePage = page < 1 ? 1L : page;
|
||||
long safeSize = pageSize < 1 ? 20L : Math.min(pageSize, MAX_PAGE_SIZE);
|
||||
LambdaQueryWrapper<UserNotificationEntity> countWrapper = baseWrapper(userId, audience, onlyUnread);
|
||||
Long totalValue = userNotificationMapper.selectCount(countWrapper);
|
||||
long total = totalValue == null ? 0L : totalValue;
|
||||
|
||||
long offset = Math.max(0L, (safePage - 1) * safeSize);
|
||||
List<UserNotificationEntity> rows = total == 0 ? List.of()
|
||||
: userNotificationMapper.selectList(baseWrapper(userId, audience, onlyUnread)
|
||||
.orderByDesc(UserNotificationEntity::getId)
|
||||
.last("limit " + offset + "," + safeSize));
|
||||
|
||||
NotificationPageVo vo = new NotificationPageVo();
|
||||
List<NotificationItemVo> items = new ArrayList<>(rows.size());
|
||||
for (UserNotificationEntity row : rows) {
|
||||
items.add(toItem(row));
|
||||
}
|
||||
vo.setItems(items);
|
||||
vo.setTotal(total);
|
||||
vo.setPage(safePage);
|
||||
vo.setPageSize(safeSize);
|
||||
vo.setUnreadCount(unreadCount(userId, audience));
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 摘要:未读数 + 最新通知 id(前端轮询判断是否有新通知)。 */
|
||||
public NotificationSummaryVo summary(Long userId, String audience) {
|
||||
NotificationSummaryVo vo = new NotificationSummaryVo();
|
||||
vo.setUnreadCount(unreadCount(userId, audience));
|
||||
UserNotificationEntity latest = userNotificationMapper.selectOne(
|
||||
new LambdaQueryWrapper<UserNotificationEntity>()
|
||||
.select(UserNotificationEntity::getId)
|
||||
.eq(UserNotificationEntity::getUserId, userId)
|
||||
.eq(UserNotificationEntity::getAudience, audience)
|
||||
.orderByDesc(UserNotificationEntity::getId)
|
||||
.last("limit 1"));
|
||||
vo.setLatestId(latest == null ? 0L : latest.getId());
|
||||
return vo;
|
||||
}
|
||||
|
||||
public long unreadCount(Long userId, String audience) {
|
||||
Long count = userNotificationMapper.selectCount(new LambdaQueryWrapper<UserNotificationEntity>()
|
||||
.eq(UserNotificationEntity::getUserId, userId)
|
||||
.eq(UserNotificationEntity::getAudience, audience)
|
||||
.isNull(UserNotificationEntity::getReadAt));
|
||||
return count == null ? 0L : count;
|
||||
}
|
||||
|
||||
/** 标记单条已读(仅限本人、本人接收端)。 */
|
||||
public boolean markRead(Long userId, String audience, Long id) {
|
||||
if (id == null || id <= 0) {
|
||||
log.warn("[notification] 标记已读参数无效 userId={} id={}", userId, id);
|
||||
return false;
|
||||
}
|
||||
int updated = userNotificationMapper.update(null, new LambdaUpdateWrapper<UserNotificationEntity>()
|
||||
.eq(UserNotificationEntity::getId, id)
|
||||
.eq(UserNotificationEntity::getUserId, userId)
|
||||
.eq(UserNotificationEntity::getAudience, audience)
|
||||
.isNull(UserNotificationEntity::getReadAt)
|
||||
.set(UserNotificationEntity::getReadAt, LocalDateTime.now()));
|
||||
if (updated == 0) {
|
||||
log.info("[notification] 标记已读未生效(不存在/非本人/已读) userId={} audience={} id={}",
|
||||
userId, audience, id);
|
||||
return false;
|
||||
}
|
||||
log.info("[notification] 已标记已读 userId={} audience={} id={}", userId, audience, id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 全部标记已读,返回受影响条数。 */
|
||||
public int markAllRead(Long userId, String audience) {
|
||||
int updated = userNotificationMapper.update(null, new LambdaUpdateWrapper<UserNotificationEntity>()
|
||||
.eq(UserNotificationEntity::getUserId, userId)
|
||||
.eq(UserNotificationEntity::getAudience, audience)
|
||||
.isNull(UserNotificationEntity::getReadAt)
|
||||
.set(UserNotificationEntity::getReadAt, LocalDateTime.now()));
|
||||
if (updated > 0) {
|
||||
log.info("[notification] 全部已读 userId={} audience={} 更新={} 条", userId, audience, updated);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** 清理指定时间之前已读的通知(保留期由调用方决定)。 */
|
||||
public int cleanupReadBefore(LocalDateTime cutoff) {
|
||||
int deleted = userNotificationMapper.delete(new LambdaQueryWrapper<UserNotificationEntity>()
|
||||
.isNotNull(UserNotificationEntity::getReadAt)
|
||||
.lt(UserNotificationEntity::getCreatedAt, cutoff));
|
||||
if (deleted > 0) {
|
||||
log.info("[notification] 清理历史已读通知 cutoff={} 删除={} 条", cutoff, deleted);
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<UserNotificationEntity> baseWrapper(Long userId, String audience, boolean onlyUnread) {
|
||||
LambdaQueryWrapper<UserNotificationEntity> wrapper = new LambdaQueryWrapper<UserNotificationEntity>()
|
||||
.eq(UserNotificationEntity::getUserId, userId)
|
||||
.eq(UserNotificationEntity::getAudience, audience);
|
||||
if (onlyUnread) {
|
||||
wrapper.isNull(UserNotificationEntity::getReadAt);
|
||||
}
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
private boolean existsByDedupeKey(String dedupeKey) {
|
||||
return selectByDedupeKey(dedupeKey) != null;
|
||||
}
|
||||
|
||||
private UserNotificationEntity selectByDedupeKey(String dedupeKey) {
|
||||
return userNotificationMapper.selectOne(new LambdaQueryWrapper<UserNotificationEntity>()
|
||||
.eq(UserNotificationEntity::getDedupeKey, dedupeKey)
|
||||
.orderByAsc(UserNotificationEntity::getId)
|
||||
.last("limit 1"));
|
||||
}
|
||||
|
||||
private NotificationItemVo toItem(UserNotificationEntity row) {
|
||||
NotificationItemVo vo = new NotificationItemVo();
|
||||
vo.setId(row.getId());
|
||||
vo.setScene(row.getScene());
|
||||
vo.setLevel(row.getLevel());
|
||||
vo.setTitle(row.getTitle());
|
||||
vo.setContent(row.getContent());
|
||||
vo.setRead(row.getReadAt() != null);
|
||||
vo.setReadAt(row.getReadAt());
|
||||
vo.setCreatedAt(row.getCreatedAt());
|
||||
return vo;
|
||||
}
|
||||
|
||||
private String normalize(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private String truncate(String value, int maxLength) {
|
||||
String normalized = value == null ? "" : value.trim();
|
||||
return normalized.length() <= maxLength ? normalized : normalized.substring(0, maxLength);
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package com.nanri.aiimage.modules.permission.support;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
|
||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||
import com.nanri.aiimage.modules.shopkey.mapper.ShopManageGroupMapper;
|
||||
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 数据权限范围解析:由主管(admin)uid 得到其可见/管辖的用户 id 集合。
|
||||
* 与后台密钥管理页(UserApiSecretService.adminPage)同源规则:
|
||||
* 自己带的「数据权限分组」成员(组长本人 + 名下子账户),无分组记录时回退按「名下子账户」兜底。
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class UserDataScopeSupport {
|
||||
|
||||
private static final int FALLBACK_LIMIT = 2000;
|
||||
|
||||
private final AdminUserMapper adminUserMapper;
|
||||
private final ShopManageGroupMapper shopManageGroupMapper;
|
||||
|
||||
/** 主管可见用户:自己 + 自己带的分组下的子账户(名下 users.created_by_id=自己)。 */
|
||||
public List<Long> resolveVisibleUserIds(Long operatorId) {
|
||||
if (operatorId == null) {
|
||||
return List.of();
|
||||
}
|
||||
List<Long> ledGroupIds = listLedGroupIds(operatorId);
|
||||
Set<Long> userIds = new LinkedHashSet<>();
|
||||
for (Long groupId : ledGroupIds) {
|
||||
userIds.addAll(shopManageGroupMapper.selectUserIdsByGroupId(groupId));
|
||||
}
|
||||
// 没有分组记录的主管(历史数据)回退按「名下子账户」兜底,避免可见范围整体为空。
|
||||
if (userIds.isEmpty()) {
|
||||
adminUserMapper.selectList(new LambdaQueryWrapper<AdminUserEntity>()
|
||||
.eq(AdminUserEntity::getCreatedById, operatorId)
|
||||
.last("limit " + FALLBACK_LIMIT))
|
||||
.forEach(user -> {
|
||||
if (user.getId() != null) {
|
||||
userIds.add(user.getId());
|
||||
}
|
||||
});
|
||||
}
|
||||
userIds.add(operatorId);
|
||||
return new ArrayList<>(userIds);
|
||||
}
|
||||
|
||||
/** 主管带的分组 ID(created_by_id / user_id = 自己)。 */
|
||||
public List<Long> listLedGroupIds(Long operatorId) {
|
||||
if (operatorId == null) {
|
||||
return List.of();
|
||||
}
|
||||
return shopManageGroupMapper.selectLedGroups(operatorId).stream()
|
||||
.map(ShopManageGroupEntity::getId)
|
||||
.filter(id -> id != null && id > 0)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
+8
-3
@@ -69,8 +69,9 @@ public class UserApiSecretService {
|
||||
private static final String STATUS_UNKNOWN = "unknown";
|
||||
private static final int MASK_MIN_LENGTH = 8;
|
||||
private static final int MESSAGE_MAX_LENGTH = 500;
|
||||
/** 代理值支持两种形态:静态代理地址 http://[user:pass@]host:port,或供应商代理提取链接(无显式端口)。 */
|
||||
private static final String PROXY_FORMAT_HINT =
|
||||
"代理地址格式不正确,应形如 http://host:port 或 http://user:pass@host:port";
|
||||
"代理地址格式不正确,请填写 http://host:port 形式的代理地址,或代理服务商的提取链接(https://...)";
|
||||
|
||||
private final UserApiSecretMapper userApiSecretMapper;
|
||||
private final ShopCredentialCryptoService cryptoService;
|
||||
@@ -786,13 +787,17 @@ public class UserApiSecretService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 代理地址校验:http(s):// 开头且含 host:port(账密可省略)。 */
|
||||
/**
|
||||
* 代理地址校验:http(s):// 开头且 host 非空即可,兼容两种形态——
|
||||
* 静态代理 http://[user:pass@]host:port,与供应商提取链接 http(s)://host/path?query(无显式端口)。
|
||||
* 2026-09-13 修复:旧实现要求必须有显式端口,jikip 提取链接(默认 443)被误判为格式错误,用户无法保存。
|
||||
*/
|
||||
private void validateProxyValue(String value) {
|
||||
try {
|
||||
URI uri = URI.create(value);
|
||||
boolean schemeOk = uri.getScheme() != null
|
||||
&& (uri.getScheme().equalsIgnoreCase("http") || uri.getScheme().equalsIgnoreCase("https"));
|
||||
if (!schemeOk || uri.getHost() == null || uri.getHost().isBlank() || uri.getPort() <= 0) {
|
||||
if (!schemeOk || uri.getHost() == null || uri.getHost().isBlank()) {
|
||||
throw new BusinessException(PROXY_FORMAT_HINT);
|
||||
}
|
||||
} catch (BusinessException ex) {
|
||||
|
||||
@@ -318,6 +318,22 @@ aiimage:
|
||||
jikip-balance-url: ${AIIMAGE_USER_SECRET_JIKIP_BALANCE_URL:https://api.jikip.com/find-balance}
|
||||
jikip-plan-id: ${AIIMAGE_USER_SECRET_JIKIP_PLAN_ID:}
|
||||
jikip-user-id: ${AIIMAGE_USER_SECRET_JIKIP_USER_ID:}
|
||||
# 巡检发现欠费/密钥失效时是否推送站内通知(铃铛)
|
||||
notify-enabled: ${AIIMAGE_USER_SECRET_NOTIFY_ENABLED:true}
|
||||
# 站内通知(铃铛):任务失败扫描 + 下游服务健康探测
|
||||
notification:
|
||||
scan-enabled: ${AIIMAGE_NOTIFICATION_SCAN_ENABLED:true}
|
||||
scan-interval-ms: ${AIIMAGE_NOTIFICATION_SCAN_INTERVAL_MS:300000}
|
||||
scan-initial-delay-ms: ${AIIMAGE_NOTIFICATION_SCAN_INITIAL_DELAY_MS:120000}
|
||||
task-scan-enabled: ${AIIMAGE_NOTIFICATION_TASK_SCAN_ENABLED:true}
|
||||
task-failed-window-minutes: ${AIIMAGE_NOTIFICATION_TASK_FAILED_WINDOW_MINUTES:60}
|
||||
task-scan-max-rows: ${AIIMAGE_NOTIFICATION_TASK_SCAN_MAX_ROWS:1000}
|
||||
service-probe-enabled: ${AIIMAGE_NOTIFICATION_SERVICE_PROBE_ENABLED:true}
|
||||
# 探测地址留空=跳过该项;部署时按主机拓扑配置(15126 品牌检测 / 18960 跟价任务 API)
|
||||
brand-service-url: ${AIIMAGE_NOTIFICATION_BRAND_SERVICE_URL:}
|
||||
price-track-api-url: ${AIIMAGE_NOTIFICATION_PRICE_TRACK_API_URL:}
|
||||
jikip-probe-enabled: ${AIIMAGE_NOTIFICATION_JIKIP_PROBE_ENABLED:true}
|
||||
read-retention-days: ${AIIMAGE_NOTIFICATION_READ_RETENTION_DAYS:90}
|
||||
security:
|
||||
shop-credential-key: ${AIIMAGE_SHOP_CREDENTIAL_KEY:change-me-shop-credential-key}
|
||||
internal-token: ${AIIMAGE_INTERNAL_TOKEN:}
|
||||
@@ -328,6 +344,7 @@ aiimage:
|
||||
cookie-name: ${AIIMAGE_AUTH_COOKIE_NAME:aiimage_token}
|
||||
cookie-secure: ${AIIMAGE_AUTH_COOKIE_SECURE:false}
|
||||
cookie-same-site: ${AIIMAGE_AUTH_COOKIE_SAME_SITE:Lax}
|
||||
single-device-enabled: ${AIIMAGE_AUTH_SINGLE_DEVICE_ENABLED:true}
|
||||
ziniao:
|
||||
enabled: ${AIIMAGE_ZINIAO_ENABLED:false}
|
||||
base-url: ${AIIMAGE_ZINIAO_BASE_URL:https://sbappstoreapi.ziniao.com/openapi-router}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
-- V116: 站内通知(铃铛)
|
||||
-- 桌面端用户与后台管理员共用的通知表,按 audience 区分接收端;
|
||||
-- 通知来源:密钥巡检(欠费/失效)、任务失败扫描、下游服务健康探测。
|
||||
-- dedupe_key 自带时间粒度(当天/当小时/任务号),插入前精确查重实现去重。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `biz_user_notification` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`user_id` BIGINT NOT NULL COMMENT '接收者ID(users.id)',
|
||||
`audience` VARCHAR(16) NOT NULL DEFAULT 'user' COMMENT '接收端:user=桌面端/admin=后台',
|
||||
`scene` VARCHAR(32) NOT NULL DEFAULT 'system' COMMENT '场景:secret_balance/secret_invalid/task_failed/service_down/system',
|
||||
`level` VARCHAR(16) NOT NULL DEFAULT 'warning' COMMENT '级别:info/warning/error',
|
||||
`title` VARCHAR(128) NOT NULL COMMENT '通知标题',
|
||||
`content` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '通知内容',
|
||||
`dedupe_key` VARCHAR(160) NOT NULL DEFAULT '' COMMENT '去重键(自带时间粒度,插入前精确查重)',
|
||||
`read_at` DATETIME NULL COMMENT '已读时间(NULL=未读)',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user_audience_read` (`user_id`, `audience`, `read_at`, `id`),
|
||||
KEY `idx_dedupe` (`dedupe_key`),
|
||||
KEY `idx_created_at` (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='站内通知(桌面端用户 + 后台管理员)';
|
||||
@@ -0,0 +1,9 @@
|
||||
-- V118: 清空 users.machine,为单设备登录(互踢)做干净起点
|
||||
-- 背景:users.machine 是早期「设备绑定」遗留列,绑定逻辑停用后一直没维护,
|
||||
-- 存量值与用户当前设备普遍不一致。单设备登录上线后校验 machine 与
|
||||
-- token 内签名 deviceId 是否一致,若不清空,历史残留会导致用户部署后
|
||||
-- 立刻被判「已在其他设备登录」而下线(提示还是错的)。
|
||||
-- 清空后:已登录用户不受影响,每人下一次登录时写入当前设备,自然进入新规则。
|
||||
-- 幂等:只清非空值,重复执行无副作用。
|
||||
|
||||
UPDATE `users` SET `machine` = NULL WHERE `machine` IS NOT NULL;
|
||||
+76
@@ -1,16 +1,23 @@
|
||||
package com.nanri.aiimage.modules.admin.support;
|
||||
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.auth.config.AuthProperties;
|
||||
import com.nanri.aiimage.modules.auth.service.JwtService;
|
||||
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
|
||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.catchThrowableOfType;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class AdminAuthSupportTest {
|
||||
|
||||
@@ -41,12 +48,81 @@ class AdminAuthSupportTest {
|
||||
assertThat(support.currentRole(user(1L, "normal", 1, null))).isNull();
|
||||
}
|
||||
|
||||
// ---------- 单设备登录:requireUser 统一拦截 ----------
|
||||
|
||||
@Test
|
||||
void requireUserRejectsTokenFromSupersededDevice() {
|
||||
// 账号已绑定 devA,token 内签名的设备是 devB(被新设备顶下线)→ 4011
|
||||
AdminAuthSupport support = support(user(7L, "normal", 0, null, "devA"), "devB", true);
|
||||
|
||||
BusinessException ex = catchThrowableOfType(
|
||||
() -> support.requireUser(requestWithBearer("t")), BusinessException.class);
|
||||
|
||||
assertThat(ex.getCode()).isEqualTo(4011);
|
||||
assertThat(ex.getMessage()).contains("已在其他设备登录");
|
||||
}
|
||||
|
||||
@Test
|
||||
void requireUserPassesWhenDeviceMatches() {
|
||||
AdminAuthSupport support = support(user(7L, "normal", 0, null, "devA"), "devA", true);
|
||||
|
||||
assertThatCode(() -> support.requireUser(requestWithBearer("t"))).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void requireUserExemptsSuperAdmin() {
|
||||
AdminAuthSupport support = support(user(7L, "super_admin", 1, null, "devA"), "devB", true);
|
||||
|
||||
assertThatCode(() -> support.requireUser(requestWithBearer("t"))).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void requireUserPassesWhenSingleDeviceDisabled() {
|
||||
AdminAuthSupport support = support(user(7L, "normal", 0, null, "devA"), "devB", false);
|
||||
|
||||
assertThatCode(() -> support.requireUser(requestWithBearer("t"))).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void requireUserPassesWhenUnbound() {
|
||||
AdminAuthSupport support = support(user(7L, "normal", 0, null, null), "devB", true);
|
||||
|
||||
assertThatCode(() -> support.requireUser(requestWithBearer("t"))).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
private AdminAuthSupport support(AdminUserEntity user, String claimDeviceId, boolean singleDeviceEnabled) {
|
||||
JwtService jwtService = mock(JwtService.class);
|
||||
AdminUserMapper userMapper = mock(AdminUserMapper.class);
|
||||
AuthProperties props = mock(AuthProperties.class);
|
||||
|
||||
Claims claims = mock(Claims.class);
|
||||
when(claims.getSubject()).thenReturn(String.valueOf(user.getId()));
|
||||
when(claims.get("deviceId")).thenReturn(claimDeviceId);
|
||||
when(jwtService.parse("t")).thenReturn(claims);
|
||||
when(userMapper.selectById(user.getId())).thenReturn(user);
|
||||
when(props.isSingleDeviceEnabled()).thenReturn(singleDeviceEnabled);
|
||||
|
||||
return new AdminAuthSupport(jwtService, userMapper, props);
|
||||
}
|
||||
|
||||
private HttpServletRequest requestWithBearer(String token) {
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
when(request.getHeader(HttpHeaders.AUTHORIZATION)).thenReturn("Bearer " + token);
|
||||
return request;
|
||||
}
|
||||
|
||||
private AdminUserEntity user(Long id, String role, int isAdmin, Long createdById) {
|
||||
return user(id, role, isAdmin, createdById, null);
|
||||
}
|
||||
|
||||
private AdminUserEntity user(Long id, String role, int isAdmin, Long createdById, String machine) {
|
||||
AdminUserEntity user = new AdminUserEntity();
|
||||
user.setId(id);
|
||||
user.setUsername("u" + id);
|
||||
user.setRole(role);
|
||||
user.setIsAdmin(isAdmin);
|
||||
user.setCreatedById(createdById);
|
||||
user.setMachine(machine);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
package com.nanri.aiimage.modules.auth.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.auth.config.AuthProperties;
|
||||
import com.nanri.aiimage.modules.auth.mapper.LoginUserMapper;
|
||||
import com.nanri.aiimage.modules.auth.model.dto.LoginRequest;
|
||||
import com.nanri.aiimage.modules.auth.model.entity.LoginUserEntity;
|
||||
import com.nanri.aiimage.modules.auth.model.vo.LoginResultVo;
|
||||
import com.nanri.aiimage.modules.auth.util.WerkzeugPasswordEncoder;
|
||||
import com.nanri.aiimage.modules.permission.service.PermissionMenuService;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.catchThrowableOfType;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class AuthServiceTest {
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// lambda 列名解析需要 MyBatis-Plus TableInfo 缓存;mock 环境手动初始化。
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
||||
LoginUserEntity.class);
|
||||
}
|
||||
|
||||
// ---------- login:绑定当前设备 ----------
|
||||
|
||||
@Test
|
||||
void loginBindsAccountToCurrentDevice() {
|
||||
Fixture f = fixture(true);
|
||||
when(f.loginUserMapper.selectOne(any())).thenReturn(f.user("devA"));
|
||||
when(f.passwordEncoder.matches("pwd", "hash")).thenReturn(true);
|
||||
when(f.jwtService.issue(eq(7L), anyString(), anyString())).thenReturn("jwt-token");
|
||||
|
||||
LoginRequest request = new LoginRequest();
|
||||
request.setUsername("u7");
|
||||
request.setPassword("pwd");
|
||||
request.setDeviceId("devB");
|
||||
|
||||
LoginResultVo vo = f.service.login(request);
|
||||
|
||||
// 绑定写入当前设备(last-login-wins),签发的 token 也用当前设备
|
||||
verify(f.loginUserMapper, times(1)).update(any(), any());
|
||||
verify(f.jwtService).issue(7L, "u7", "devB");
|
||||
assertThat(vo.getDeviceId()).isEqualTo("devB");
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginSkipsBindingWhenSingleDeviceDisabled() {
|
||||
Fixture f = fixture(false);
|
||||
when(f.loginUserMapper.selectOne(any())).thenReturn(f.user("devA"));
|
||||
when(f.passwordEncoder.matches("pwd", "hash")).thenReturn(true);
|
||||
when(f.jwtService.issue(eq(7L), anyString(), anyString())).thenReturn("jwt-token");
|
||||
|
||||
LoginRequest request = new LoginRequest();
|
||||
request.setUsername("u7");
|
||||
request.setPassword("pwd");
|
||||
request.setDeviceId("devB");
|
||||
|
||||
f.service.login(request);
|
||||
|
||||
verify(f.loginUserMapper, never()).update(any(), any());
|
||||
}
|
||||
|
||||
// ---------- check_login:被顶下线必须拦截且不续期 ----------
|
||||
|
||||
@Test
|
||||
void checkLoginRejectsSupersededDeviceWithoutRenewal() {
|
||||
Fixture f = fixture(true);
|
||||
Claims claims = f.claims("devB");
|
||||
when(f.jwtService.parse("t")).thenReturn(claims);
|
||||
when(f.loginUserMapper.selectById(7L)).thenReturn(f.user("devA"));
|
||||
|
||||
BusinessException ex = catchThrowableOfType(() -> f.service.checkLogin("t", null), BusinessException.class);
|
||||
|
||||
assertThat(ex.getCode()).isEqualTo(4011);
|
||||
assertThat(ex.getMessage()).contains("已在其他设备登录");
|
||||
// 关键:被踢的旧 token 不能在这里换到新 token「复活」
|
||||
verify(f.jwtService, never()).issue(any(), anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkLoginPassesAndRenewsWithClaimDevice() {
|
||||
Fixture f = fixture(true);
|
||||
Claims claims = f.claims("devA");
|
||||
when(f.jwtService.parse("t")).thenReturn(claims);
|
||||
when(f.loginUserMapper.selectById(7L)).thenReturn(f.user("devA"));
|
||||
when(f.jwtService.issue(eq(7L), anyString(), anyString())).thenReturn("jwt-token");
|
||||
|
||||
LoginResultVo vo = f.service.checkLogin("t", null);
|
||||
|
||||
verify(f.jwtService).issue(7L, "u7", "devA");
|
||||
assertThat(vo.getDeviceId()).isEqualTo("devA");
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkLoginIgnoresSpoofedHeaderDevice() {
|
||||
// 校验以 token 内签名的 deviceId 为准:请求头塞别的设备号不影响判定,也不能签进新 token
|
||||
Fixture f = fixture(true);
|
||||
Claims claims = f.claims("devB");
|
||||
when(f.jwtService.parse("t")).thenReturn(claims);
|
||||
when(f.loginUserMapper.selectById(7L)).thenReturn(f.user("devA"));
|
||||
|
||||
BusinessException ex = catchThrowableOfType(
|
||||
() -> f.service.checkLogin("t", "devA"), BusinessException.class);
|
||||
|
||||
assertThat(ex.getCode()).isEqualTo(4011);
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkLoginExemptsSuperAdmin() {
|
||||
Fixture f = fixture(true);
|
||||
Claims claims = f.claims("devB");
|
||||
when(f.jwtService.parse("t")).thenReturn(claims);
|
||||
LoginUserEntity root = f.user("devA");
|
||||
root.setRole("super_admin");
|
||||
root.setIsAdmin(1);
|
||||
when(f.loginUserMapper.selectById(7L)).thenReturn(root);
|
||||
when(f.jwtService.issue(eq(7L), anyString(), anyString())).thenReturn("jwt-token");
|
||||
|
||||
assertThatCode(() -> f.service.checkLogin("t", null)).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkLoginPassesWhenSingleDeviceDisabled() {
|
||||
Fixture f = fixture(false);
|
||||
Claims claims = f.claims("devB");
|
||||
when(f.jwtService.parse("t")).thenReturn(claims);
|
||||
when(f.loginUserMapper.selectById(7L)).thenReturn(f.user("devA"));
|
||||
when(f.jwtService.issue(eq(7L), anyString(), anyString())).thenReturn("jwt-token");
|
||||
|
||||
assertThatCode(() -> f.service.checkLogin("t", null)).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
// ---------- 夹具 ----------
|
||||
|
||||
private Fixture fixture(boolean singleDeviceEnabled) {
|
||||
LoginUserMapper loginUserMapper = mock(LoginUserMapper.class);
|
||||
WerkzeugPasswordEncoder passwordEncoder = mock(WerkzeugPasswordEncoder.class);
|
||||
JwtService jwtService = mock(JwtService.class);
|
||||
PermissionMenuService permissionMenuService = mock(PermissionMenuService.class);
|
||||
AuthProperties authProperties = mock(AuthProperties.class);
|
||||
when(authProperties.isSingleDeviceEnabled()).thenReturn(singleDeviceEnabled);
|
||||
when(jwtService.ttlSeconds()).thenReturn(604800L);
|
||||
AuthService service = new AuthService(loginUserMapper, passwordEncoder, jwtService,
|
||||
permissionMenuService, authProperties);
|
||||
return new Fixture(service, loginUserMapper, passwordEncoder, jwtService);
|
||||
}
|
||||
|
||||
private record Fixture(AuthService service, LoginUserMapper loginUserMapper,
|
||||
WerkzeugPasswordEncoder passwordEncoder, JwtService jwtService) {
|
||||
|
||||
LoginUserEntity user(String machine) {
|
||||
LoginUserEntity user = new LoginUserEntity();
|
||||
user.setId(7L);
|
||||
user.setUsername("u7");
|
||||
user.setPasswordHash("hash");
|
||||
user.setIsAdmin(0);
|
||||
user.setRole("normal");
|
||||
user.setMachine(machine);
|
||||
return user;
|
||||
}
|
||||
|
||||
Claims claims(String deviceId) {
|
||||
Claims claims = mock(Claims.class);
|
||||
when(claims.getSubject()).thenReturn("7");
|
||||
when(claims.get("deviceId")).thenReturn(deviceId);
|
||||
return claims;
|
||||
}
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package com.nanri.aiimage.modules.auth.support;
|
||||
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.catchThrowableOfType;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class DeviceSessionPolicyTest {
|
||||
|
||||
// ---------- 角色规则(从 AdminAuthSupport.currentRole 迁移,行为必须保持一致) ----------
|
||||
|
||||
@Test
|
||||
void explicitSuperAdminResolved() {
|
||||
assertThat(DeviceSessionPolicy.resolveRole("super_admin", 1, 9L)).isEqualTo("super_admin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void explicitAdminNotPromotedToSuperAdmin() {
|
||||
assertThat(DeviceSessionPolicy.resolveRole("admin", 1, null)).isEqualTo("admin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyBlankRoleRootRemainsSuperAdmin() {
|
||||
assertThat(DeviceSessionPolicy.resolveRole(null, 1, null)).isEqualTo("super_admin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyBlankRoleCreatedByOtherIsAdmin() {
|
||||
assertThat(DeviceSessionPolicy.resolveRole("", 1, 3L)).isEqualTo("admin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void normalRoleIsNotPromotedByLegacyAdminFields() {
|
||||
assertThat(DeviceSessionPolicy.resolveRole("normal", 1, null)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isSuperAdminOnlyForSuperAdmin() {
|
||||
assertThat(DeviceSessionPolicy.isSuperAdmin("super_admin", 1, null)).isTrue();
|
||||
assertThat(DeviceSessionPolicy.isSuperAdmin("admin", 1, null)).isFalse();
|
||||
assertThat(DeviceSessionPolicy.isSuperAdmin("normal", 0, null)).isFalse();
|
||||
}
|
||||
|
||||
// ---------- 设备一致性校验 ----------
|
||||
|
||||
@Test
|
||||
void exemptUserAlwaysPasses() {
|
||||
// 超管豁免:设备不一致也放行
|
||||
assertThatCode(() -> DeviceSessionPolicy.assertSameDevice("devA", "devB", true, 7L, "root"))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void unboundMachinePasses() {
|
||||
// 尚未绑定(machine 为空)放行,下次登录写入后开始生效
|
||||
assertThatCode(() -> DeviceSessionPolicy.assertSameDevice(null, "devB", false, 7L, "u1"))
|
||||
.doesNotThrowAnyException();
|
||||
assertThatCode(() -> DeviceSessionPolicy.assertSameDevice(" ", "devB", false, 7L, "u1"))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameDevicePasses() {
|
||||
assertThatCode(() -> DeviceSessionPolicy.assertSameDevice("devA", "devA", false, 7L, "u1"))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentDeviceThrowsKicked() {
|
||||
BusinessException ex = catchThrowableOfType(
|
||||
() -> DeviceSessionPolicy.assertSameDevice("devA", "devB", false, 7L, "u1"),
|
||||
BusinessException.class);
|
||||
|
||||
assertThat(ex.getCode()).isEqualTo(DeviceSessionPolicy.CODE_KICKED);
|
||||
assertThat(ex.getMessage()).contains("已在其他设备登录");
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankClaimDeviceThrowsUnauthorized() {
|
||||
// 正常 token 必带 deviceId claim;缺失按登录态无效处理(普通 401,不误报"被踢")
|
||||
BusinessException ex = catchThrowableOfType(
|
||||
() -> DeviceSessionPolicy.assertSameDevice("devA", null, false, 7L, "u1"),
|
||||
BusinessException.class);
|
||||
|
||||
assertThat(ex.getCode()).isEqualTo(401);
|
||||
}
|
||||
|
||||
@Test
|
||||
void claimDeviceIdExtractsAndTrims() {
|
||||
assertThat(DeviceSessionPolicy.claimDeviceId(null)).isEmpty();
|
||||
|
||||
Claims claims = mock(Claims.class);
|
||||
when(claims.get("deviceId")).thenReturn(" devA ");
|
||||
assertThat(DeviceSessionPolicy.claimDeviceId(claims)).isEqualTo("devA");
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package com.nanri.aiimage.modules.notification.service;
|
||||
|
||||
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
||||
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
|
||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||
import com.nanri.aiimage.modules.permission.support.UserDataScopeSupport;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class NotificationDispatchServiceTest {
|
||||
|
||||
private final NotificationService notificationService = mock(NotificationService.class);
|
||||
private final AdminUserMapper adminUserMapper = mock(AdminUserMapper.class);
|
||||
private final AdminAuthSupport adminAuthSupport = mock(AdminAuthSupport.class);
|
||||
private final UserDataScopeSupport userDataScopeSupport = mock(UserDataScopeSupport.class);
|
||||
|
||||
private final NotificationDispatchService service = new NotificationDispatchService(
|
||||
notificationService, adminUserMapper, adminAuthSupport, userDataScopeSupport);
|
||||
|
||||
@Test
|
||||
void prepareAudienceFiltersNonAdminAndCachesScopes() {
|
||||
AdminUserEntity superAdmin = admin(1L, "超管甲");
|
||||
AdminUserEntity admin = admin(2L, "主管乙");
|
||||
AdminUserEntity normal = admin(3L, "员工丙");
|
||||
when(adminUserMapper.selectList(any())).thenReturn(List.of(superAdmin, admin, normal));
|
||||
when(adminAuthSupport.currentRole(superAdmin)).thenReturn("super_admin");
|
||||
when(adminAuthSupport.currentRole(admin)).thenReturn("admin");
|
||||
when(adminAuthSupport.currentRole(normal)).thenReturn(null);
|
||||
when(userDataScopeSupport.resolveVisibleUserIds(2L)).thenReturn(List.of(2L, 20L, 21L));
|
||||
|
||||
NotificationDispatchService.AdminAudience audience = service.prepareAdminAudience();
|
||||
|
||||
assertThat(audience.admins()).containsExactly(superAdmin, admin);
|
||||
assertThat(audience.superAdminIds()).containsExactly(1L);
|
||||
assertThat(audience.visibleByAdmin()).containsOnlyKeys(2L);
|
||||
// 超管全量;主管仅可见自己管辖用户;全局事件(subject=null)所有人可见
|
||||
assertThat(audience.canReceive(1L, 999L)).isTrue();
|
||||
assertThat(audience.canReceive(2L, 20L)).isTrue();
|
||||
assertThat(audience.canReceive(2L, 999L)).isFalse();
|
||||
assertThat(audience.canReceive(2L, null)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void pushToAdminsSkipsAdminOutOfDataScope() {
|
||||
AdminUserEntity superAdmin = admin(1L, "超管甲");
|
||||
AdminUserEntity admin = admin(2L, "主管乙");
|
||||
when(adminUserMapper.selectList(any())).thenReturn(List.of(superAdmin, admin));
|
||||
when(adminAuthSupport.currentRole(superAdmin)).thenReturn("super_admin");
|
||||
when(adminAuthSupport.currentRole(admin)).thenReturn("admin");
|
||||
when(userDataScopeSupport.resolveVisibleUserIds(2L)).thenReturn(List.of(2L));
|
||||
when(notificationService.pushOrRefresh(anyLong(), anyString(), anyString(), anyString(), anyString(), anyString(), anyString()))
|
||||
.thenReturn(true);
|
||||
|
||||
NotificationDispatchService.AdminAudience audience = service.prepareAdminAudience();
|
||||
int pushed = service.pushToAdmins(audience, NotificationService.SCENE_TASK_FAILED,
|
||||
NotificationService.LEVEL_WARNING, "标题", "内容", "task_failed_admin:20:PRICE_TRACK:2026091310", 20L);
|
||||
|
||||
// 主管乙不可见用户 20 → 只有超管收到
|
||||
assertThat(pushed).isEqualTo(1);
|
||||
org.mockito.Mockito.verify(notificationService).pushOrRefresh(eq(1L), eq(NotificationService.AUDIENCE_ADMIN),
|
||||
eq(NotificationService.SCENE_TASK_FAILED), eq(NotificationService.LEVEL_WARNING),
|
||||
eq("标题"), eq("内容"), eq("task_failed_admin:20:PRICE_TRACK:2026091310:1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void pushToUserUsesUserAudience() {
|
||||
when(notificationService.pushOrRefresh(anyLong(), anyString(), anyString(), anyString(), anyString(), anyString(), anyString()))
|
||||
.thenReturn(true);
|
||||
|
||||
boolean pushed = service.pushToUser(7L, NotificationService.SCENE_SECRET_BALANCE,
|
||||
NotificationService.LEVEL_ERROR, "标题", "内容", "secret_balance:7:proxy:20260913");
|
||||
|
||||
assertThat(pushed).isTrue();
|
||||
org.mockito.Mockito.verify(notificationService).pushOrRefresh(eq(7L), eq(NotificationService.AUDIENCE_USER),
|
||||
eq(NotificationService.SCENE_SECRET_BALANCE), eq(NotificationService.LEVEL_ERROR),
|
||||
eq("标题"), eq("内容"), eq("secret_balance:7:proxy:20260913"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void displayNameFallsBackToUidWhenMissing() {
|
||||
AdminUserEntity user = admin(7L, "张三");
|
||||
when(adminUserMapper.selectById(7L)).thenReturn(user);
|
||||
when(adminUserMapper.selectById(8L)).thenReturn(null);
|
||||
|
||||
assertThat(service.displayNameOf(7L)).isEqualTo("张三");
|
||||
assertThat(service.displayNameOf(8L)).isEqualTo("用户#8");
|
||||
assertThat(service.displayNameOf(null)).isEqualTo("未知用户");
|
||||
}
|
||||
|
||||
private AdminUserEntity admin(Long id, String username) {
|
||||
AdminUserEntity user = new AdminUserEntity();
|
||||
user.setId(id);
|
||||
user.setUsername(username);
|
||||
user.setIsAdmin(1);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
package com.nanri.aiimage.modules.notification.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.nanri.aiimage.config.NotificationProperties;
|
||||
import com.nanri.aiimage.modules.brand.mapper.BrandCrawlTaskMapper;
|
||||
import com.nanri.aiimage.modules.brand.model.entity.BrandCrawlTaskEntity;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||
import com.nanri.aiimage.modules.usersecret.client.JikipProxyClient;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class NotificationScanSchedulerTest {
|
||||
|
||||
/** 单测无 Spring 上下文,需手动注册实体 TableInfo 供 LambdaWrapper 解析列名。 */
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
|
||||
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
|
||||
TableInfoHelper.initTableInfo(assistant, BrandCrawlTaskEntity.class);
|
||||
}
|
||||
|
||||
private final FileTaskMapper fileTaskMapper = mock(FileTaskMapper.class);
|
||||
private final BrandCrawlTaskMapper brandCrawlTaskMapper = mock(BrandCrawlTaskMapper.class);
|
||||
private final NotificationService notificationService = mock(NotificationService.class);
|
||||
private final NotificationDispatchService dispatch = mock(NotificationDispatchService.class);
|
||||
private final com.nanri.aiimage.common.service.DistributedJobLockService lockService =
|
||||
mock(com.nanri.aiimage.common.service.DistributedJobLockService.class);
|
||||
private final NotificationProperties properties = new NotificationProperties();
|
||||
private final JikipProxyClient jikipProxyClient = mock(JikipProxyClient.class);
|
||||
|
||||
private NotificationScanScheduler newScheduler() {
|
||||
return new NotificationScanScheduler(fileTaskMapper, brandCrawlTaskMapper, notificationService,
|
||||
dispatch, lockService, properties, jikipProxyClient);
|
||||
}
|
||||
|
||||
@Test
|
||||
void scanGroupsFailedTasksByUserAndModuleAndRefreshesSameBucket() {
|
||||
when(fileTaskMapper.selectList(any())).thenReturn(List.of(
|
||||
fileTask(101L, "T-101", "PRICE_TRACK", 7L, "浏览器启动失败"),
|
||||
fileTask(102L, "T-102", "PRICE_TRACK", 7L, "cookie 失效"),
|
||||
fileTask(103L, "T-103", "SIMILAR_ASIN", 8L, null)));
|
||||
BrandCrawlTaskEntity brandTask = new BrandCrawlTaskEntity();
|
||||
brandTask.setId(201L);
|
||||
brandTask.setUserId(7L);
|
||||
brandTask.setErrorMessage("品牌检测服务不可达");
|
||||
when(brandCrawlTaskMapper.selectList(any())).thenReturn(List.of(brandTask));
|
||||
when(dispatch.prepareAdminAudience()).thenReturn(
|
||||
new NotificationDispatchService.AdminAudience(List.of(), Set.of(), Map.of()));
|
||||
when(dispatch.displayNameOf(anyLong())).thenReturn("张三");
|
||||
when(notificationService.pushOrRefresh(anyLong(), anyString(), anyString(), anyString(), anyString(), anyString(), anyString()))
|
||||
.thenReturn(true);
|
||||
when(dispatch.pushToAdmins(any(NotificationDispatchService.AdminAudience.class), anyString(), anyString(),
|
||||
anyString(), anyString(), anyString(), any())).thenReturn(1);
|
||||
|
||||
newScheduler().scanFailedTasks();
|
||||
|
||||
// 三个桶:用户7×跟价、用户7×品牌检测、用户8×货源查询
|
||||
ArgumentCaptor<String> contentCaptor = ArgumentCaptor.forClass(String.class);
|
||||
verify(notificationService, times(3)).pushOrRefresh(anyLong(), eq(NotificationService.AUDIENCE_USER),
|
||||
eq(NotificationService.SCENE_TASK_FAILED), eq(NotificationService.LEVEL_WARNING),
|
||||
anyString(), contentCaptor.capture(), anyString());
|
||||
List<String> contents = contentCaptor.getAllValues();
|
||||
assertThat(contents).anySatisfy(text -> {
|
||||
assertThat(text).contains("2 个跟价任务失败");
|
||||
assertThat(text).contains("T-101");
|
||||
assertThat(text).contains("T-102");
|
||||
});
|
||||
assertThat(contents).anySatisfy(text -> assertThat(text).contains("1 个货源查询任务失败"));
|
||||
assertThat(contents).anySatisfy(text -> assertThat(text).contains("1 个品牌检测任务失败"));
|
||||
verify(dispatch, times(3)).pushToAdmins(any(NotificationDispatchService.AdminAudience.class),
|
||||
eq(NotificationService.SCENE_TASK_FAILED), eq(NotificationService.LEVEL_WARNING),
|
||||
anyString(), anyString(), anyString(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void scanDoesNothingWhenNoFailedTasks() {
|
||||
when(fileTaskMapper.selectList(any())).thenReturn(List.of());
|
||||
when(brandCrawlTaskMapper.selectList(any())).thenReturn(List.of());
|
||||
|
||||
newScheduler().scanFailedTasks();
|
||||
|
||||
verify(notificationService, org.mockito.Mockito.never()).pushOrRefresh(anyLong(), anyString(), anyString(),
|
||||
anyString(), anyString(), anyString(), anyString());
|
||||
verify(dispatch, org.mockito.Mockito.never()).pushToAdmins(any(), anyString(), anyString(),
|
||||
anyString(), anyString(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void scanSkipsRowsWithoutUserId() {
|
||||
when(fileTaskMapper.selectList(any())).thenReturn(List.of(
|
||||
fileTask(101L, "T-101", "PRICE_TRACK", null, "无主任务")));
|
||||
when(brandCrawlTaskMapper.selectList(any())).thenReturn(List.of());
|
||||
|
||||
newScheduler().scanFailedTasks();
|
||||
|
||||
verify(notificationService, org.mockito.Mockito.never()).pushOrRefresh(anyLong(), anyString(), anyString(),
|
||||
anyString(), anyString(), anyString(), anyString());
|
||||
}
|
||||
|
||||
private FileTaskEntity fileTask(Long id, String taskNo, String moduleType, Long userId, String errorMessage) {
|
||||
FileTaskEntity task = new FileTaskEntity();
|
||||
task.setId(id);
|
||||
task.setTaskNo(taskNo);
|
||||
task.setModuleType(moduleType);
|
||||
task.setUserId(userId);
|
||||
task.setErrorMessage(errorMessage);
|
||||
task.setStatus("FAILED");
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
return task;
|
||||
}
|
||||
}
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
package com.nanri.aiimage.modules.notification.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.nanri.aiimage.modules.notification.mapper.UserNotificationMapper;
|
||||
import com.nanri.aiimage.modules.notification.model.entity.UserNotificationEntity;
|
||||
import com.nanri.aiimage.modules.notification.model.vo.NotificationPageVo;
|
||||
import com.nanri.aiimage.modules.notification.model.vo.NotificationSummaryVo;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class NotificationServiceTest {
|
||||
|
||||
/** 单测无 Spring 上下文,需手动注册实体 TableInfo 供 LambdaWrapper 解析列名。 */
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
||||
UserNotificationEntity.class);
|
||||
}
|
||||
|
||||
private final UserNotificationMapper mapper = mock(UserNotificationMapper.class);
|
||||
private final NotificationService service = new NotificationService(mapper);
|
||||
|
||||
@Test
|
||||
void pushSkipsWhenDedupeKeyExists() {
|
||||
UserNotificationEntity existing = new UserNotificationEntity();
|
||||
existing.setId(1L);
|
||||
existing.setDedupeKey("secret_balance:7:proxy:20260913");
|
||||
when(mapper.selectOne(any())).thenReturn(existing);
|
||||
|
||||
boolean pushed = service.push(7L, NotificationService.AUDIENCE_USER,
|
||||
NotificationService.SCENE_SECRET_BALANCE, NotificationService.LEVEL_ERROR,
|
||||
"代理欠费", "余额不足", "secret_balance:7:proxy:20260913");
|
||||
|
||||
assertThat(pushed).isFalse();
|
||||
verify(mapper, never()).insert(any(UserNotificationEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void pushInsertsNormalizedRowWhenNoDuplicate() {
|
||||
when(mapper.selectOne(any())).thenReturn(null);
|
||||
when(mapper.insert(any(UserNotificationEntity.class))).thenReturn(1);
|
||||
|
||||
boolean pushed = service.push(7L, NotificationService.AUDIENCE_USER,
|
||||
NotificationService.SCENE_SECRET_BALANCE, NotificationService.LEVEL_ERROR,
|
||||
" 代理欠费 ", " 余额不足 ", "secret_balance:7:proxy:20260913");
|
||||
|
||||
assertThat(pushed).isTrue();
|
||||
ArgumentCaptor<UserNotificationEntity> captor = ArgumentCaptor.forClass(UserNotificationEntity.class);
|
||||
verify(mapper).insert(captor.capture());
|
||||
UserNotificationEntity row = captor.getValue();
|
||||
assertThat(row.getUserId()).isEqualTo(7L);
|
||||
assertThat(row.getAudience()).isEqualTo("user");
|
||||
assertThat(row.getScene()).isEqualTo("secret_balance");
|
||||
assertThat(row.getLevel()).isEqualTo("error");
|
||||
assertThat(row.getTitle()).isEqualTo("代理欠费");
|
||||
assertThat(row.getContent()).isEqualTo("余额不足");
|
||||
assertThat(row.getDedupeKey()).isEqualTo("secret_balance:7:proxy:20260913");
|
||||
assertThat(row.getCreatedAt()).isNotNull();
|
||||
assertThat(row.getReadAt()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void pushRejectsInvalidReceiver() {
|
||||
assertThat(service.push(null, "user", "system", "info", "t", "c", "")).isFalse();
|
||||
assertThat(service.push(0L, "user", "system", "info", "t", "c", "")).isFalse();
|
||||
verify(mapper, never()).insert(any(UserNotificationEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void pushOrRefreshUpdatesContentAndResetsUnread() {
|
||||
UserNotificationEntity existing = new UserNotificationEntity();
|
||||
existing.setId(9L);
|
||||
existing.setTitle("跟价任务失败");
|
||||
existing.setContent("最近 60 分钟内有 2 个跟价任务失败");
|
||||
existing.setLevel("warning");
|
||||
existing.setReadAt(LocalDateTime.now());
|
||||
when(mapper.selectOne(any())).thenReturn(existing);
|
||||
|
||||
boolean refreshed = service.pushOrRefresh(7L, NotificationService.AUDIENCE_ADMIN,
|
||||
NotificationService.SCENE_TASK_FAILED, NotificationService.LEVEL_WARNING,
|
||||
"跟价任务失败", "最近 60 分钟内有 5 个跟价任务失败", "task_failed:7:PRICE_TRACK:2026091310");
|
||||
|
||||
assertThat(refreshed).isTrue();
|
||||
verify(mapper, never()).insert(any(UserNotificationEntity.class));
|
||||
verify(mapper, times(1)).update(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pushOrRefreshKeepsRowWhenContentUnchanged() {
|
||||
UserNotificationEntity existing = new UserNotificationEntity();
|
||||
existing.setId(9L);
|
||||
existing.setTitle("跟价任务失败");
|
||||
existing.setContent("最近 60 分钟内有 2 个跟价任务失败");
|
||||
when(mapper.selectOne(any())).thenReturn(existing);
|
||||
|
||||
boolean refreshed = service.pushOrRefresh(7L, NotificationService.AUDIENCE_USER,
|
||||
NotificationService.SCENE_TASK_FAILED, NotificationService.LEVEL_WARNING,
|
||||
"跟价任务失败", "最近 60 分钟内有 2 个跟价任务失败", "task_failed:7:PRICE_TRACK:2026091310");
|
||||
|
||||
assertThat(refreshed).isFalse();
|
||||
verify(mapper, never()).update(any(), any());
|
||||
verify(mapper, never()).insert(any(UserNotificationEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void pageReturnsItemsTotalAndUnreadCount() {
|
||||
UserNotificationEntity first = new UserNotificationEntity();
|
||||
first.setId(11L);
|
||||
first.setTitle("a");
|
||||
first.setReadAt(LocalDateTime.now());
|
||||
UserNotificationEntity second = new UserNotificationEntity();
|
||||
second.setId(10L);
|
||||
second.setTitle("b");
|
||||
when(mapper.selectCount(any())).thenReturn(2L, 1L);
|
||||
when(mapper.selectList(any())).thenReturn(List.of(first, second));
|
||||
|
||||
NotificationPageVo page = service.page(7L, NotificationService.AUDIENCE_USER, 1, 20, false);
|
||||
|
||||
assertThat(page.getItems()).hasSize(2);
|
||||
assertThat(page.getItems().get(0).getRead()).isTrue();
|
||||
assertThat(page.getItems().get(1).getRead()).isFalse();
|
||||
assertThat(page.getTotal()).isEqualTo(2L);
|
||||
assertThat(page.getUnreadCount()).isEqualTo(1L);
|
||||
assertThat(page.getPage()).isEqualTo(1L);
|
||||
assertThat(page.getPageSize()).isEqualTo(20L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pageClampsPageSizeAndSkipsQueryWhenEmpty() {
|
||||
when(mapper.selectCount(any())).thenReturn(0L, 0L);
|
||||
|
||||
NotificationPageVo page = service.page(7L, NotificationService.AUDIENCE_ADMIN, 0, 500, true);
|
||||
|
||||
assertThat(page.getItems()).isEmpty();
|
||||
assertThat(page.getPage()).isEqualTo(1L);
|
||||
assertThat(page.getPageSize()).isEqualTo(100L);
|
||||
verify(mapper, never()).selectList(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void summaryReturnsUnreadCountAndLatestId() {
|
||||
UserNotificationEntity latest = new UserNotificationEntity();
|
||||
latest.setId(42L);
|
||||
when(mapper.selectCount(any())).thenReturn(3L);
|
||||
when(mapper.selectOne(any())).thenReturn(latest);
|
||||
|
||||
NotificationSummaryVo summary = service.summary(7L, NotificationService.AUDIENCE_USER);
|
||||
|
||||
assertThat(summary.getUnreadCount()).isEqualTo(3L);
|
||||
assertThat(summary.getLatestId()).isEqualTo(42L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void markReadReturnsFalseWhenNothingUpdated() {
|
||||
when(mapper.update(any(), any())).thenReturn(0);
|
||||
assertThat(service.markRead(7L, NotificationService.AUDIENCE_USER, 5L)).isFalse();
|
||||
|
||||
when(mapper.update(any(), any())).thenReturn(1);
|
||||
assertThat(service.markRead(7L, NotificationService.AUDIENCE_USER, 5L)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void markAllReadReturnsUpdatedCount() {
|
||||
when(mapper.update(any(), any())).thenReturn(4);
|
||||
assertThat(service.markAllRead(7L, NotificationService.AUDIENCE_ADMIN)).isEqualTo(4);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupDeletesOnlyReadRowsBeforeCutoff() {
|
||||
when(mapper.delete(any())).thenReturn(2);
|
||||
int deleted = service.cleanupReadBefore(LocalDateTime.now().minusDays(90));
|
||||
assertThat(deleted).isEqualTo(2);
|
||||
verify(mapper).delete(any());
|
||||
}
|
||||
}
|
||||
+28
@@ -192,6 +192,34 @@ class UserApiSecretServiceTest {
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.save(7L, "proxy", "1.2.3.4:8080"))
|
||||
.isInstanceOf(com.nanri.aiimage.common.exception.BusinessException.class)
|
||||
.hasMessageContaining("代理地址格式不正确");
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.save(7L, "proxy", "随便写点什么"))
|
||||
.isInstanceOf(com.nanri.aiimage.common.exception.BusinessException.class)
|
||||
.hasMessageContaining("代理地址格式不正确");
|
||||
}
|
||||
|
||||
/** 用户界面上保存的代理多为 jikip 提取链接(无显式端口,默认 443),必须允许保存。 */
|
||||
@Test
|
||||
void saveAcceptsProxyExtractionLink() {
|
||||
UserApiSecretService service = newService();
|
||||
when(mapper.selectOne(any())).thenReturn(null);
|
||||
|
||||
service.save(7L, "proxy",
|
||||
"https://api.jikip.com/ip-get?num=1&minute=3&format=json&area=all&protocol=1&mode=2&key=6p78gjm9c0p161o");
|
||||
|
||||
ArgumentCaptor<UserApiSecretEntity> captor = ArgumentCaptor.forClass(UserApiSecretEntity.class);
|
||||
verify(mapper).insert(captor.capture());
|
||||
assertThat(captor.getValue().getModuleKey()).isEqualTo("proxy");
|
||||
assertThat(captor.getValue().getSecretValue()).contains("api.jikip.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveAcceptsStaticProxyWithCredentials() {
|
||||
UserApiSecretService service = newService();
|
||||
when(mapper.selectOne(any())).thenReturn(null);
|
||||
|
||||
service.save(7L, "proxy", "http://user:pass@1.2.3.4:8080");
|
||||
|
||||
verify(mapper).insert(any(UserApiSecretEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
</nav>
|
||||
|
||||
<div class="top-right">
|
||||
<NotificationBell />
|
||||
<BrandApiSecretSettingsButton variant="topbar" />
|
||||
<span class="admin-name">{{ username }}</span>
|
||||
</div>
|
||||
@@ -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'
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<NotificationBell theme="light" />
|
||||
<span class="username-text">{{ username || '未登录' }}</span>
|
||||
<router-link :to="logoutHref" class="logout-link">退出</router-link>
|
||||
</div>
|
||||
@@ -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('')
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
<div class="login-box">
|
||||
<h1 class="login-title">登录</h1>
|
||||
<p v-if="kickedNotice" class="kicked-msg">该账号已在其他设备登录,本设备已下线。如非本人操作,请及时修改密码。</p>
|
||||
<p v-if="errorMessage" class="error-msg">{{ errorMessage }}</p>
|
||||
<form id="loginForm" @submit.prevent="submitLogin">
|
||||
<div class="form-group">
|
||||
@@ -98,6 +99,7 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { loginWithDevice } from '@/shared/api/user'
|
||||
import { KICK_NOTICE_KEY } from '@/shared/auth/kick-handler'
|
||||
import { useVersionUpdate } from '@/shared/composables/useVersionUpdate'
|
||||
import { clearApiSecretCache } from '@/shared/utils/api-secret-store'
|
||||
import UpdateProgressBar from '@/shared/components/UpdateProgressBar.vue'
|
||||
@@ -116,6 +118,7 @@ const password = ref('')
|
||||
const passwordVisible = ref(false)
|
||||
const loggingIn = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const kickedNotice = ref(false)
|
||||
const rememberPassword = ref(false)
|
||||
const autoLogin = ref(false)
|
||||
const updateOpen = ref(false)
|
||||
@@ -244,6 +247,18 @@ function hasLogoutParam(): boolean {
|
||||
return params.get('logout') === '1' || params.get('switch') === '1'
|
||||
}
|
||||
|
||||
/** 被新设备顶下线后跳回登录页(query 会被路由守卫吞掉,故另看 localStorage 标记) */
|
||||
function isKickedNav(): boolean {
|
||||
try {
|
||||
if (new URLSearchParams(window.location.search || '').get('kicked') === '1') {
|
||||
return true
|
||||
}
|
||||
return lsGet(KICK_NOTICE_KEY) === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function togglePassword() {
|
||||
passwordVisible.value = !passwordVisible.value
|
||||
}
|
||||
@@ -431,10 +446,24 @@ onMounted(() => {
|
||||
lsSet(AUTO_LOGIN_KEY, '0')
|
||||
}
|
||||
|
||||
if (isKickedNav()) {
|
||||
// 被新设备顶下线:提示原因并关闭自动登录——否则本机每次启动都会静默重登,
|
||||
// 把对方又顶下线,两台机器来回互踢。标记展示后即清(避免登出时误显示)。
|
||||
kickedNotice.value = true
|
||||
autoLogin.value = false
|
||||
lsSet(AUTO_LOGIN_KEY, '0')
|
||||
lsRemove(KICK_NOTICE_KEY)
|
||||
}
|
||||
|
||||
// 恢复记住的凭据并尝试自动登录(桌面与网页形态一致;登出/切号导航由 tryAutoLogin 内部豁免)
|
||||
// loadCredentials 现为异步(AES 解密),须先恢复密码再触发自动登录
|
||||
void (async () => {
|
||||
await loadCredentials()
|
||||
if (kickedNotice.value) {
|
||||
// 刚被顶下线:不自动重登,等用户手动点登录(此时顶掉对方,最后登录者胜)
|
||||
autoLogin.value = false
|
||||
return
|
||||
}
|
||||
tryAutoLogin()
|
||||
})()
|
||||
})
|
||||
@@ -592,6 +621,19 @@ body {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 单设备登录:被新设备顶下线的提示 */
|
||||
.kicked-msg {
|
||||
color: #b8860b;
|
||||
background: #fdf6e3;
|
||||
border: 1px solid #f0d9a0;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 记住密码 / 自动登录 */
|
||||
.login-options {
|
||||
display: flex;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<header class="setup-header">
|
||||
<span class="setup-title">数富AI</span>
|
||||
<div class="setup-header-right">
|
||||
<NotificationBell />
|
||||
<span class="username-text">{{ username || '未登录' }}</span>
|
||||
<router-link to="/login?logout=1" class="logout-link">退出</router-link>
|
||||
</div>
|
||||
@@ -34,8 +35,8 @@ import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import ApiSecretSettingsPanel from '@/shared/components/ApiSecretSettingsPanel.vue'
|
||||
import NotificationBell from '@/shared/components/NotificationBell.vue'
|
||||
import {
|
||||
checkApiSecret,
|
||||
getStoredApiSecretSnapshot,
|
||||
listApiSecretModules,
|
||||
loadApiSecrets,
|
||||
@@ -47,7 +48,7 @@ const route = useRoute()
|
||||
const panelRef = ref<InstanceType<typeof ApiSecretSettingsPanel> | null>(null)
|
||||
const submitting = ref(false)
|
||||
const username = ref('')
|
||||
const hint = ref('保存后系统会自动检测密钥连通性;检测未通过的密钥需要修正后重试。')
|
||||
const hint = ref('填写密钥并保存后,请点击对应模块的「检测」按钮确认可用,全部检测通过后即可进入工具台。')
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
@@ -57,21 +58,7 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
/** 对已保存但尚未检测出结果的必填模块补一次检测,避免"已配置却因未检测被拦"。 */
|
||||
async function ensureAllChecked() {
|
||||
for (const module of listApiSecretModules()) {
|
||||
const moduleKey = module.moduleKey as ApiSecretModuleKey
|
||||
const snapshot = getStoredApiSecretSnapshot(moduleKey)
|
||||
if (!snapshot.exists) continue
|
||||
if (snapshot.checkStatus === 'passed' || snapshot.checkStatus === 'error') continue
|
||||
try {
|
||||
await checkApiSecret(moduleKey)
|
||||
} catch (error) {
|
||||
console.warn('[api-secret] 补齐检测失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 提交:保存后只校验检测状态;检测一律由用户手动点击「检测」触发(2026-09-13 起不再自动补检)。 */
|
||||
async function submit() {
|
||||
if (submitting.value) return
|
||||
const panel = panelRef.value
|
||||
@@ -81,14 +68,13 @@ async function submit() {
|
||||
const saved = await panel.saveAll({ requireAll: true })
|
||||
if (!saved) return
|
||||
|
||||
await ensureAllChecked()
|
||||
const state = await loadApiSecrets({ force: true })
|
||||
if (state === 'incomplete') {
|
||||
const failed = listApiSecretModules()
|
||||
.map((module) => getStoredApiSecretSnapshot(module.moduleKey as ApiSecretModuleKey))
|
||||
.filter((snapshot) => !snapshot.exists || (snapshot.checkStatus !== 'passed' && snapshot.checkStatus !== 'error'))
|
||||
hint.value = `以下密钥尚未通过检测:${failed.map((item) => item.masked || '未配置').join('、')},请点击「检测」确认。`
|
||||
ElMessage.warning('密钥尚未全部检测通过,请逐项检测确认')
|
||||
hint.value = `以下密钥尚未通过检测:${failed.map((item) => item.masked || '未配置').join('、')},请点击上方的「检测」按钮逐项确认后重试。`
|
||||
ElMessage.warning('密钥尚未全部检测通过,请点击「检测」按钮逐项确认')
|
||||
return
|
||||
}
|
||||
const redirect = typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/')
|
||||
|
||||
@@ -209,6 +209,12 @@ export const API_ENDPOINTS = {
|
||||
migrate: '/api/user-secrets/migrate',
|
||||
proxyBalance: '/api/user-secrets/proxy-balance',
|
||||
},
|
||||
notification: {
|
||||
summary: '/api/notifications/summary',
|
||||
list: '/api/notifications',
|
||||
read: '/api/notifications/{id}/read',
|
||||
readAll: '/api/notifications/read-all',
|
||||
},
|
||||
collectData: {
|
||||
parse: '/api/collect-data/parse',
|
||||
countryPreference: '/api/collect-data/country-preference',
|
||||
|
||||
@@ -5,6 +5,7 @@ import axios, {
|
||||
type AxiosResponse,
|
||||
type InternalAxiosRequestConfig,
|
||||
} from 'axios'
|
||||
import { handleKicked, isKickedPayload } from '../auth/kick-handler.ts'
|
||||
|
||||
export interface LegacyApiSuccess<T> {
|
||||
success: true
|
||||
@@ -50,6 +51,27 @@ export function extractErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败'
|
||||
}
|
||||
|
||||
/** 从 axios 错误对象里取响应体(兼容真实 AxiosError 与测试桩)。 */
|
||||
function responsePayloadOf(error: unknown): unknown {
|
||||
return (error as { response?: { data?: unknown } } | null | undefined)?.response?.data
|
||||
}
|
||||
|
||||
/** 响应成功分支:Java 业务错误是 HTTP 200 + body.code,互踢下线必须在这里也判。 */
|
||||
export function onResponseFulfilled(response: AxiosResponse): AxiosResponse {
|
||||
if (isKickedPayload(response.data)) {
|
||||
handleKicked()
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
/** 响应失败分支:折叠为 Error 之前先判互踢下线(HTTP 401 等状态码路径)。 */
|
||||
export function onResponseRejected(error: unknown): Promise<never> {
|
||||
if (isKickedPayload(responsePayloadOf(error))) {
|
||||
handleKicked()
|
||||
}
|
||||
return Promise.reject(new Error(extractErrorMessage(error)))
|
||||
}
|
||||
|
||||
function createHttpClient(): AxiosInstance {
|
||||
const instance = axios.create({
|
||||
withCredentials: true,
|
||||
@@ -74,10 +96,7 @@ function createHttpClient(): AxiosInstance {
|
||||
return config
|
||||
})
|
||||
|
||||
instance.interceptors.response.use(
|
||||
(response: AxiosResponse) => response,
|
||||
(error: unknown) => Promise.reject(new Error(extractErrorMessage(error))),
|
||||
)
|
||||
instance.interceptors.response.use(onResponseFulfilled, onResponseRejected)
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { get, post, type JavaApiResponse, unwrapJavaResponse } from '../../http.ts'
|
||||
import { buildJavaUrl } from '../../url.ts'
|
||||
import { API_ENDPOINTS } from '../../endpoints.ts'
|
||||
|
||||
/** 通知场景:密钥欠费 / 密钥失效 / 任务失败 / 服务异常 / 系统。 */
|
||||
export type NotificationScene = 'secret_balance' | 'secret_invalid' | 'task_failed' | 'service_down' | 'system'
|
||||
|
||||
export type NotificationLevel = 'info' | 'warning' | 'error'
|
||||
|
||||
export interface NotificationItem {
|
||||
id: number
|
||||
scene: string
|
||||
level: string
|
||||
title: string
|
||||
content: string
|
||||
read: boolean
|
||||
readAt: string | null
|
||||
createdAt: string | null
|
||||
}
|
||||
|
||||
export interface NotificationPage {
|
||||
items: NotificationItem[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
unreadCount: number
|
||||
}
|
||||
|
||||
export interface NotificationSummary {
|
||||
unreadCount: number
|
||||
latestId: number
|
||||
}
|
||||
|
||||
/** 铃铛轮询:未读数 + 最新通知 id(latestId 增大表示有新通知)。 */
|
||||
export function fetchNotificationSummary() {
|
||||
return unwrapJavaResponse(
|
||||
get<JavaApiResponse<NotificationSummary>>(buildJavaUrl(API_ENDPOINTS.notification.summary)),
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchNotificationList(params: { page?: number; pageSize?: number; onlyUnread?: boolean } = {}) {
|
||||
return unwrapJavaResponse(
|
||||
get<JavaApiResponse<NotificationPage>>(
|
||||
buildJavaUrl(API_ENDPOINTS.notification.list, {
|
||||
page: params.page ?? 1,
|
||||
pageSize: params.pageSize ?? 20,
|
||||
onlyUnread: params.onlyUnread ? 'true' : 'false',
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function markNotificationRead(id: number) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<boolean>>(
|
||||
buildJavaUrl(API_ENDPOINTS.notification.read.replace('{id}', encodeURIComponent(String(id)))),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function markAllNotificationsRead() {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<number>>(buildJavaUrl(API_ENDPOINTS.notification.readAll)),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 单设备登录(互踢)下线处理。
|
||||
*
|
||||
* 账号在新设备登录后,旧设备的下一次受保护请求会拿到 code=4011;
|
||||
* 这里统一做四件事:清本地登录态 → 关闭自动登录 → 通知桌面端清 current_uid → 跳登录页。
|
||||
*
|
||||
* 关闭自动登录是关键:否则本机每次启动都会静默重登,把对方又顶下线(两台机器来回互踢)。
|
||||
* 记住的账号密码保留,手动点一次即可重新登录(此时顶掉对方,最后登录者胜)。
|
||||
*/
|
||||
|
||||
const AUTH_TOKEN_KEY = 'aiimage_auth_token'
|
||||
const UID_KEY = 'uid'
|
||||
const USERNAME_KEY = 'username'
|
||||
const AUTO_LOGIN_KEY = 'aiimage_auto_login'
|
||||
|
||||
/** 登录页据此标记显示"已被顶下线"警示(路由守卫会吞掉 query,故用 localStorage 兜底)。 */
|
||||
export const KICK_NOTICE_KEY = 'aiimage_kick_notice'
|
||||
|
||||
/** 服务端「已在其他设备登录」业务码,与后端 DeviceSessionPolicy.CODE_KICKED 对齐。 */
|
||||
export const KICKED_CODE = 4011
|
||||
|
||||
/** 本次页面生命周期内是否已发起跳转(并发 4011 只跳一次;整页跳转后 window 重建自然重置)。 */
|
||||
type KickWindow = Window & { __aiimageKickRedirected?: boolean }
|
||||
|
||||
/** 响应体(Java 错误信封 HTTP 200 + body.code)是否为互踢下线。 */
|
||||
export function isKickedPayload(payload: unknown): boolean {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return false
|
||||
}
|
||||
return (payload as { code?: unknown }).code === KICKED_CODE
|
||||
}
|
||||
|
||||
/** 清桌面端 Python 侧的登录用户标记(未登录/换号后任务回退全局代理池)。 */
|
||||
function clearDesktopCurrentUid(): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
const apply = () => {
|
||||
try {
|
||||
const bridge = (
|
||||
window as unknown as {
|
||||
pywebview?: { api?: { save_config?: (data: Record<string, unknown>) => Promise<unknown> } }
|
||||
}
|
||||
).pywebview
|
||||
if (bridge?.api && typeof bridge.api.save_config === 'function') {
|
||||
void bridge.api.save_config({ current_uid: '' })
|
||||
}
|
||||
} catch {
|
||||
/* 网页形态无 pywebview 桥,忽略 */
|
||||
}
|
||||
}
|
||||
apply()
|
||||
// 被顶下线常发生在页面刚加载时,此刻 pywebview 桥可能尚未就绪(调用会被丢弃):
|
||||
// 等桥就绪事件后再补一次。重复写同值幂等;网页形态该事件不会触发。
|
||||
try {
|
||||
window.addEventListener('pywebviewready', apply, { once: true })
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
/** 被顶下线:清登录态并回登录页;并发/重复触发安全(清理可重复,跳转只发一次)。 */
|
||||
export function handleKicked(): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const storage = window.localStorage
|
||||
storage.removeItem(AUTH_TOKEN_KEY)
|
||||
storage.removeItem(UID_KEY)
|
||||
storage.removeItem(USERNAME_KEY)
|
||||
storage.setItem(AUTO_LOGIN_KEY, '0')
|
||||
storage.setItem(KICK_NOTICE_KEY, '1')
|
||||
} catch {
|
||||
/* 忽略存储异常 */
|
||||
}
|
||||
clearDesktopCurrentUid()
|
||||
// 先清 uid 再整页跳转:否则路由守卫会把"已登录"的用户带回首页
|
||||
try {
|
||||
const kickWindow = window as KickWindow
|
||||
if (!kickWindow.__aiimageKickRedirected && !window.location.pathname.startsWith('/login')) {
|
||||
kickWindow.__aiimageKickRedirected = true
|
||||
console.log('[auth] 账号已在其他设备登录,本设备下线,跳转登录页')
|
||||
window.location.assign('/login?kicked=1')
|
||||
}
|
||||
} catch {
|
||||
/* 忽略跳转异常 */
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@
|
||||
<div class="secret-card-head">
|
||||
<div>
|
||||
<div class="secret-card-title">代理设置</div>
|
||||
<div class="secret-card-desc">供客户端任务连接代理服务。</div>
|
||||
<div class="secret-card-desc">供客户端任务连接代理服务,支持静态代理地址或代理服务商的提取链接。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
v-model="proxyUrl"
|
||||
class="secret-input"
|
||||
type="text"
|
||||
placeholder="请输入代理地址"
|
||||
placeholder="如 http://user:pass@host:port 或代理提取链接"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
:disabled="!proxyReady || busy"
|
||||
@@ -454,8 +454,9 @@ async function loadBalance() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存:逐模块保存非空输入(空输入保留服务端原值),代理仅在改动时保存;
|
||||
* 保存后对有输入值的模块自动检测一次,让用户立即知道密钥是否可用。
|
||||
* 保存:逐模块保存非空输入(空输入保留服务端原值),代理仅在改动时保存。
|
||||
* 保存后不再自动检测(2026-09-13 起检测一律由用户手动点击触发),
|
||||
* 保存会把检测状态重置为未检测,需用户点「检测」确认可用性。
|
||||
*/
|
||||
async function saveAll(options: { requireAll?: boolean } = {}): Promise<boolean> {
|
||||
if (busy.value || proxyLoading.value) return false
|
||||
@@ -491,15 +492,7 @@ async function saveAll(options: { requireAll?: boolean } = {}): Promise<boolean>
|
||||
await syncProxyToServer(nextProxyUrl)
|
||||
}
|
||||
|
||||
// 保存成功的模块立即自动检测,结果直接反映在卡片状态行
|
||||
for (const module of pendingModules) {
|
||||
const moduleKey = module.moduleKey as ApiSecretModuleKey
|
||||
try {
|
||||
ensureModuleState(module.moduleKey).result = await checkApiSecret(moduleKey)
|
||||
} catch (error) {
|
||||
console.warn('[api-secret] 保存后自动检测失败:', error)
|
||||
}
|
||||
}
|
||||
// 保存后不再自动检测:检测一律由用户手动点击「检测」触发
|
||||
refreshSnapshots()
|
||||
return true
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,533 @@
|
||||
<template>
|
||||
<div ref="rootRef" class="notification-bell" :class="`notification-bell--${theme}`">
|
||||
<button
|
||||
type="button"
|
||||
class="bell-trigger"
|
||||
:class="{ 'bell-trigger--active': panelOpen }"
|
||||
:title="unreadCount > 0 ? `通知(${unreadCount} 条未读)` : '通知'"
|
||||
aria-label="通知"
|
||||
@click="togglePanel"
|
||||
>
|
||||
<svg
|
||||
class="bell-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
|
||||
</svg>
|
||||
<span v-if="badgeText" class="bell-badge">{{ badgeText }}</span>
|
||||
</button>
|
||||
|
||||
<div v-if="panelOpen" class="bell-panel" role="dialog" aria-label="通知列表">
|
||||
<div class="bell-panel-head">
|
||||
<span class="bell-panel-title">通知</span>
|
||||
<button
|
||||
v-if="unreadCount > 0"
|
||||
type="button"
|
||||
class="bell-read-all"
|
||||
:disabled="markingAll"
|
||||
@click="readAll"
|
||||
>
|
||||
{{ markingAll ? '处理中...' : '全部已读' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && !items.length" class="bell-empty">正在加载...</div>
|
||||
<div v-else-if="loadError && !items.length" class="bell-empty bell-empty--error">{{ loadError }}</div>
|
||||
<div v-else-if="!items.length" class="bell-empty">暂无通知</div>
|
||||
<ul v-else class="bell-list">
|
||||
<li
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="bell-item"
|
||||
:class="[`bell-item--${item.level || 'warning'}`, { 'bell-item--unread': !item.read }]"
|
||||
@click="onItemClick(item)"
|
||||
>
|
||||
<div class="bell-item-head">
|
||||
<span class="bell-item-title">{{ item.title }}</span>
|
||||
<span class="bell-item-time">{{ formatTime(item.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="bell-item-content">{{ item.content }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="bell-panel-foot">
|
||||
<button v-if="hasMore" type="button" class="bell-more" :disabled="loading" @click="loadMore">
|
||||
{{ loading ? '加载中...' : '加载更多' }}
|
||||
</button>
|
||||
<span v-else-if="items.length" class="bell-foot-note">已显示全部</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import {
|
||||
fetchNotificationList,
|
||||
fetchNotificationSummary,
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead,
|
||||
type NotificationItem,
|
||||
} from '@/shared/api/types/modules/notification.ts'
|
||||
import {
|
||||
NOTIFICATION_POLL_INTERVAL_MS,
|
||||
currentNotificationUid,
|
||||
formatNotificationTime,
|
||||
formatUnreadBadge,
|
||||
hasNewNotification,
|
||||
readLastNotifiedId,
|
||||
writeLastNotifiedId,
|
||||
} from '@/shared/utils/notification-bell.ts'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
/** 顶栏主题:工具页/配置页深色(dark),桌面入口首页浅色(light)。 */
|
||||
const props = withDefaults(defineProps<{ theme?: 'dark' | 'light' }>(), {
|
||||
theme: 'dark',
|
||||
})
|
||||
const theme = computed(() => props.theme)
|
||||
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
const uid = currentNotificationUid()
|
||||
|
||||
const unreadCount = ref(0)
|
||||
const items = ref<NotificationItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const panelOpen = ref(false)
|
||||
const loading = ref(false)
|
||||
const markingAll = ref(false)
|
||||
const loadError = ref('')
|
||||
|
||||
const badgeText = computed(() => formatUnreadBadge(unreadCount.value))
|
||||
const hasMore = computed(() => items.value.length < total.value)
|
||||
|
||||
let pollTimer: number | null = null
|
||||
|
||||
/** 拉未读数:发现新通知提醒一次(localStorage 记录已提醒过的最大 id,避免重复弹)。 */
|
||||
async function refreshSummary() {
|
||||
try {
|
||||
const summary = await fetchNotificationSummary()
|
||||
unreadCount.value = Number(summary?.unreadCount ?? 0)
|
||||
const latestId = Number(summary?.latestId ?? 0)
|
||||
if (hasNewNotification(latestId, readLastNotifiedId(uid))) {
|
||||
ElMessage.warning('收到新的告警通知,请点击右上角铃铛查看')
|
||||
writeLastNotifiedId(uid, latestId)
|
||||
console.log('[notification] 检测到新通知 latestId=', latestId)
|
||||
}
|
||||
} catch (error) {
|
||||
// 通知接口失败静默降级:不显示红点、不打扰用户
|
||||
console.warn('[notification] 未读数刷新失败(静默降级):', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPage(targetPage: number) {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await fetchNotificationList({ page: targetPage, pageSize: PAGE_SIZE })
|
||||
const list = Array.isArray(result?.items) ? result.items : []
|
||||
items.value = targetPage <= 1 ? list : [...items.value, ...list]
|
||||
total.value = Number(result?.total ?? items.value.length)
|
||||
unreadCount.value = Number(result?.unreadCount ?? unreadCount.value)
|
||||
page.value = targetPage
|
||||
loadError.value = ''
|
||||
if (targetPage <= 1) {
|
||||
const latest = items.value.length ? Number(items.value[0].id) : 0
|
||||
if (latest > 0) {
|
||||
writeLastNotifiedId(uid, Math.max(readLastNotifiedId(uid), latest))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 列表加载失败在面板内提示,不弹全局消息打扰用户
|
||||
console.warn('[notification] 通知列表加载失败:', error)
|
||||
loadError.value = error instanceof Error ? error.message : '通知加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (!hasMore.value) return
|
||||
void loadPage(page.value + 1)
|
||||
}
|
||||
|
||||
function togglePanel() {
|
||||
panelOpen.value = !panelOpen.value
|
||||
if (panelOpen.value) {
|
||||
void loadPage(1)
|
||||
}
|
||||
}
|
||||
|
||||
async function onItemClick(item: NotificationItem) {
|
||||
if (item.read) return
|
||||
try {
|
||||
await markNotificationRead(item.id)
|
||||
item.read = true
|
||||
unreadCount.value = Math.max(0, unreadCount.value - 1)
|
||||
} catch (error) {
|
||||
console.warn('[notification] 标记已读失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function readAll() {
|
||||
if (markingAll.value || unreadCount.value === 0) return
|
||||
markingAll.value = true
|
||||
try {
|
||||
await markAllNotificationsRead()
|
||||
for (const item of items.value) {
|
||||
item.read = true
|
||||
}
|
||||
unreadCount.value = 0
|
||||
} catch (error) {
|
||||
console.warn('[notification] 全部已读失败:', error)
|
||||
ElMessage.error(error instanceof Error ? error.message : '操作失败')
|
||||
} finally {
|
||||
markingAll.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(value: string | null) {
|
||||
return formatNotificationTime(value)
|
||||
}
|
||||
|
||||
/** 点击组件外部关闭面板。 */
|
||||
function onDocumentMouseDown(event: MouseEvent) {
|
||||
if (!panelOpen.value) return
|
||||
const root = rootRef.value
|
||||
if (root && event.target instanceof Node && !root.contains(event.target)) {
|
||||
panelOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 页面回到前台时立即刷新一次未读数。 */
|
||||
function onVisibilityChange() {
|
||||
if (typeof document !== 'undefined' && document.visibilityState === 'visible') {
|
||||
void refreshSummary()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousedown', onDocumentMouseDown)
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
void refreshSummary()
|
||||
pollTimer = window.setInterval(() => {
|
||||
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
|
||||
return
|
||||
}
|
||||
void refreshSummary()
|
||||
}, NOTIFICATION_POLL_INTERVAL_MS)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousedown', onDocumentMouseDown)
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
if (pollTimer != null) {
|
||||
window.clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notification-bell {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bell-trigger {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #c8d2e2;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.bell-trigger:hover,
|
||||
.bell-trigger--active {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #f5f8fc;
|
||||
}
|
||||
|
||||
.bell-icon {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
}
|
||||
|
||||
.bell-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 1px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 999px;
|
||||
background: #e5484d;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bell-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
right: 0;
|
||||
z-index: 3200;
|
||||
width: 360px;
|
||||
max-width: calc(100vw - 32px);
|
||||
max-height: 440px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #2c3540;
|
||||
border-radius: 12px;
|
||||
background: #171b20;
|
||||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.55);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bell-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid #262d35;
|
||||
}
|
||||
|
||||
.bell-panel-title {
|
||||
color: #eef4fb;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bell-read-all {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #8dc4ff;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.bell-read-all:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.bell-empty {
|
||||
padding: 32px 0;
|
||||
color: #7f8a96;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bell-empty--error {
|
||||
color: #ff9b9b;
|
||||
}
|
||||
|
||||
.bell-list {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.bell-item {
|
||||
padding: 11px 14px;
|
||||
border-bottom: 1px solid #222830;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
.bell-item:hover {
|
||||
background: #1d242c;
|
||||
}
|
||||
|
||||
.bell-item--unread {
|
||||
background: #1b232e;
|
||||
}
|
||||
|
||||
.bell-item--unread .bell-item-title::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin-right: 7px;
|
||||
border-radius: 50%;
|
||||
background: #e5484d;
|
||||
vertical-align: 1px;
|
||||
}
|
||||
|
||||
.bell-item--error .bell-item-title {
|
||||
color: #ffb4b4;
|
||||
}
|
||||
|
||||
.bell-item--warning .bell-item-title {
|
||||
color: #f0c674;
|
||||
}
|
||||
|
||||
.bell-item--info .bell-item-title {
|
||||
color: #8dc4ff;
|
||||
}
|
||||
|
||||
.bell-item-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bell-item-title {
|
||||
color: #dce6f0;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bell-item-time {
|
||||
flex-shrink: 0;
|
||||
color: #6f7a86;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.bell-item-content {
|
||||
margin-top: 4px;
|
||||
color: #9aa6b3;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.bell-panel-foot {
|
||||
border-top: 1px solid #262d35;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bell-more {
|
||||
width: 100%;
|
||||
padding: 9px 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #8dc4ff;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bell-more:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.bell-foot-note {
|
||||
display: block;
|
||||
padding: 8px 0;
|
||||
color: #6f7a86;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ---- 浅色主题(桌面入口首页顶栏) ---- */
|
||||
.notification-bell--light .bell-trigger {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.notification-bell--light .bell-trigger:hover,
|
||||
.notification-bell--light .bell-trigger--active {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.notification-bell--light .bell-panel {
|
||||
border-color: #e2e8f0;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 18px 48px rgba(31, 45, 61, 0.18);
|
||||
}
|
||||
|
||||
.notification-bell--light .bell-panel-head {
|
||||
border-bottom-color: #edf1f5;
|
||||
}
|
||||
|
||||
.notification-bell--light .bell-panel-title {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.notification-bell--light .bell-read-all {
|
||||
color: #4c5bd4;
|
||||
}
|
||||
|
||||
.notification-bell--light .bell-empty {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.notification-bell--light .bell-item {
|
||||
border-bottom-color: #f0f3f7;
|
||||
}
|
||||
|
||||
.notification-bell--light .bell-item:hover {
|
||||
background: #f7f9fc;
|
||||
}
|
||||
|
||||
.notification-bell--light .bell-item--unread {
|
||||
background: #f2f6ff;
|
||||
}
|
||||
|
||||
.notification-bell--light .bell-item-title {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.notification-bell--light .bell-item--error .bell-item-title {
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
.notification-bell--light .bell-item--warning .bell-item-title {
|
||||
color: #a8793e;
|
||||
}
|
||||
|
||||
.notification-bell--light .bell-item--info .bell-item-title {
|
||||
color: #4c5bd4;
|
||||
}
|
||||
|
||||
.notification-bell--light .bell-item-time {
|
||||
color: #9aa6b3;
|
||||
}
|
||||
|
||||
.notification-bell--light .bell-item-content {
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.notification-bell--light .bell-panel-foot {
|
||||
border-top-color: #edf1f5;
|
||||
}
|
||||
|
||||
.notification-bell--light .bell-more {
|
||||
color: #4c5bd4;
|
||||
}
|
||||
|
||||
.notification-bell--light .bell-foot-note {
|
||||
color: #9aa6b3;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 铃铛通知纯逻辑:未读徽标文案、新通知判定、「已提醒过」去重记录、时间展示。
|
||||
* 轮询调度与渲染在 NotificationBell.vue;这里只放可单测的纯函数与存储读写。
|
||||
*/
|
||||
|
||||
/** 未读轮询间隔(60 秒)。 */
|
||||
export const NOTIFICATION_POLL_INTERVAL_MS = 60_000
|
||||
|
||||
const NOTIFIED_KEY_PREFIX = '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): string {
|
||||
return `${NOTIFIED_KEY_PREFIX}:${uid || '0'}`
|
||||
}
|
||||
|
||||
/** 读取当前用户「已提醒过的最大通知 id」(多页面/多会话共享)。 */
|
||||
export function readLastNotifiedId(uid: string): 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, id: number): void {
|
||||
try {
|
||||
if (Number.isFinite(id) && id > 0) {
|
||||
window.localStorage.setItem(storageKey(uid), String(Math.floor(id)))
|
||||
}
|
||||
} catch {
|
||||
/* 存储不可用时静默忽略(退化为每次都提醒,不影响功能) */
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前登录用户 uid(未登录返回 '0')。 */
|
||||
export function currentNotificationUid(): string {
|
||||
try {
|
||||
return window.localStorage.getItem('uid') || '0'
|
||||
} catch {
|
||||
return '0'
|
||||
}
|
||||
}
|
||||
|
||||
/** 通知时间展示:今天只显示 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}`
|
||||
}
|
||||
@@ -19,6 +19,7 @@ const MODULE_KEYS = [
|
||||
'similarAsin',
|
||||
'appearancePatent',
|
||||
'userSecret',
|
||||
'notification',
|
||||
'collectData',
|
||||
'imageVideo',
|
||||
'brand',
|
||||
@@ -316,6 +317,12 @@ test('test_endpoints_frozen_snapshot', () => {
|
||||
"migrate": "/api/user-secrets/migrate",
|
||||
"proxyBalance": "/api/user-secrets/proxy-balance"
|
||||
},
|
||||
"notification": {
|
||||
"summary": "/api/notifications/summary",
|
||||
"list": "/api/notifications",
|
||||
"read": "/api/notifications/{id}/read",
|
||||
"readAll": "/api/notifications/read-all"
|
||||
},
|
||||
"collectData": {
|
||||
"parse": "/api/collect-data/parse",
|
||||
"countryPreference": "/api/collect-data/country-preference",
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
formatNotificationTime,
|
||||
formatUnreadBadge,
|
||||
hasNewNotification,
|
||||
readLastNotifiedId,
|
||||
writeLastNotifiedId,
|
||||
} from '../src/shared/utils/notification-bell.ts'
|
||||
|
||||
function createStorage() {
|
||||
const store = new Map<string, string>()
|
||||
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()
|
||||
;(globalThis as Record<string, unknown>).window = { localStorage }
|
||||
return { localStorage }
|
||||
}
|
||||
|
||||
test('formatUnreadBadge 徽标文案:0 与非法值为空、超过 99 显示 99+', () => {
|
||||
assert.equal(formatUnreadBadge(0), '')
|
||||
assert.equal(formatUnreadBadge(-1), '')
|
||||
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+')
|
||||
assert.equal(formatUnreadBadge(9999), '99+')
|
||||
})
|
||||
|
||||
test('hasNewNotification 新通知判定:latestId 需大于已提醒 id', () => {
|
||||
assert.equal(hasNewNotification(0, 0), false)
|
||||
assert.equal(hasNewNotification(null, 0), false)
|
||||
assert.equal(hasNewNotification(5, 5), false)
|
||||
assert.equal(hasNewNotification(5, 7), false)
|
||||
assert.equal(hasNewNotification(5, 3), true)
|
||||
assert.equal(hasNewNotification(5, 0), true)
|
||||
assert.equal(hasNewNotification(5, null), true)
|
||||
})
|
||||
|
||||
test('已提醒 id 按用户读写并容错', () => {
|
||||
setupWindow()
|
||||
assert.equal(readLastNotifiedId('42'), 0, '未写入时按 0 处理')
|
||||
|
||||
writeLastNotifiedId('42', 123)
|
||||
assert.equal(readLastNotifiedId('42'), 123)
|
||||
assert.equal(readLastNotifiedId('43'), 0, '不同用户互不影响')
|
||||
|
||||
writeLastNotifiedId('42', 0)
|
||||
assert.equal(readLastNotifiedId('42'), 123, '非法 id 不覆盖已有值')
|
||||
|
||||
window.localStorage.setItem('notification:last-notified-id:42', 'broken')
|
||||
assert.equal(readLastNotifiedId('42'), 0, '损坏值按 0 处理')
|
||||
})
|
||||
|
||||
test('formatNotificationTime 今天/昨天/更早展示', () => {
|
||||
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), '')
|
||||
})
|
||||
@@ -0,0 +1,172 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { onResponseFulfilled, onResponseRejected } from '../src/shared/api/http.ts'
|
||||
import {
|
||||
KICK_NOTICE_KEY,
|
||||
KICKED_CODE,
|
||||
handleKicked,
|
||||
isKickedPayload,
|
||||
} from '../src/shared/auth/kick-handler.ts'
|
||||
|
||||
function createStorage(initial: Record<string, string> = {}) {
|
||||
const store = new Map(Object.entries(initial))
|
||||
return {
|
||||
getItem: (key: string) => (store.has(key) ? (store.get(key) as string) : null),
|
||||
setItem: (key: string, value: string) => void store.set(key, String(value)),
|
||||
removeItem: (key: string) => void store.delete(key),
|
||||
has: (key: string) => store.has(key),
|
||||
}
|
||||
}
|
||||
|
||||
function setupWindow(initial: Record<string, string> = {}, pathname = '/home') {
|
||||
const localStorage = createStorage(initial)
|
||||
const assigned: string[] = []
|
||||
const listeners = new Map<string, Array<() => void>>()
|
||||
const fakeWindow: Record<string, unknown> = {
|
||||
localStorage,
|
||||
location: {
|
||||
pathname,
|
||||
search: '',
|
||||
assign: (url: string) => {
|
||||
assigned.push(url)
|
||||
},
|
||||
},
|
||||
addEventListener: (type: string, handler: () => void) => {
|
||||
const list = listeners.get(type) || []
|
||||
list.push(handler)
|
||||
listeners.set(type, list)
|
||||
},
|
||||
}
|
||||
const dispatch = (type: string) => {
|
||||
for (const handler of listeners.get(type) || []) handler()
|
||||
}
|
||||
;(globalThis as Record<string, unknown>).window = fakeWindow
|
||||
return { localStorage, assigned, dispatch, fakeWindow }
|
||||
}
|
||||
|
||||
/** 登录态齐全的初始存储(含桌面端设备号,互踢下线不应清掉它) */
|
||||
function loggedInStorage() {
|
||||
return {
|
||||
aiimage_auth_token: 'jwt-token',
|
||||
uid: '1095',
|
||||
username: 'ceshi001',
|
||||
aiimage_auto_login: '1',
|
||||
aiimage_device_id: 'dev-fingerprint',
|
||||
aiimage_remember_user: 'ceshi001',
|
||||
}
|
||||
}
|
||||
|
||||
test('isKickedPayload 只认 code=4011', () => {
|
||||
assert.equal(isKickedPayload({ success: false, code: KICKED_CODE, message: '该账号已在其他设备登录' }), true)
|
||||
assert.equal(isKickedPayload({ success: false, code: 401, message: '未登录' }), false)
|
||||
assert.equal(isKickedPayload({ success: true, data: {} }), false)
|
||||
assert.equal(isKickedPayload(null), false)
|
||||
assert.equal(isKickedPayload('4011'), false)
|
||||
})
|
||||
|
||||
test('handleKicked 清登录态并关自动登录,跳登录页带 kicked 标记', () => {
|
||||
const { localStorage, assigned } = setupWindow(loggedInStorage())
|
||||
|
||||
handleKicked()
|
||||
|
||||
assert.equal(localStorage.getItem('aiimage_auth_token'), null)
|
||||
assert.equal(localStorage.getItem('uid'), null)
|
||||
assert.equal(localStorage.getItem('username'), null)
|
||||
// 被踢后不再自动重登,否则本机每次启动都会静默重登把对方又顶下线
|
||||
assert.equal(localStorage.getItem('aiimage_auto_login'), '0')
|
||||
assert.equal(localStorage.getItem(KICK_NOTICE_KEY), '1')
|
||||
// 设备号与记住的账号保留:同设备重登不算新设备,账号回填方便手动重登
|
||||
assert.equal(localStorage.getItem('aiimage_device_id'), 'dev-fingerprint')
|
||||
assert.equal(localStorage.getItem('aiimage_remember_user'), 'ceshi001')
|
||||
assert.deepEqual(assigned, ['/login?kicked=1'])
|
||||
})
|
||||
|
||||
test('handleKicked 已在登录页时不跳转(仍清理登录态)', () => {
|
||||
const { localStorage, assigned } = setupWindow(loggedInStorage(), '/login')
|
||||
|
||||
handleKicked()
|
||||
|
||||
assert.equal(localStorage.getItem('uid'), null)
|
||||
assert.equal(localStorage.getItem(KICK_NOTICE_KEY), '1')
|
||||
assert.deepEqual(assigned, [])
|
||||
})
|
||||
|
||||
test('handleKicked 并发重复触发只跳转一次且状态一致', () => {
|
||||
const { localStorage, assigned } = setupWindow(loggedInStorage())
|
||||
|
||||
handleKicked()
|
||||
handleKicked()
|
||||
|
||||
assert.equal(localStorage.getItem('uid'), null)
|
||||
assert.equal(localStorage.getItem('aiimage_auto_login'), '0')
|
||||
assert.deepEqual(assigned, ['/login?kicked=1'])
|
||||
})
|
||||
|
||||
test('handleKicked 桌面端同步清 Python 侧 current_uid', () => {
|
||||
const { localStorage, fakeWindow } = setupWindow(loggedInStorage())
|
||||
const saved: Array<Record<string, unknown>> = []
|
||||
fakeWindow.pywebview = { api: { save_config: (data: Record<string, unknown>) => void saved.push(data) } }
|
||||
|
||||
handleKicked()
|
||||
|
||||
assert.deepEqual(saved, [{ current_uid: '' }])
|
||||
})
|
||||
|
||||
test('handleKicked 桥未就绪时等 pywebviewready 补清 current_uid', () => {
|
||||
// 真机复现:被顶下线发生在页面刚加载时,pywebview 桥尚未就绪,立即调用会被丢弃
|
||||
const { dispatch, fakeWindow } = setupWindow(loggedInStorage())
|
||||
const saved: Array<Record<string, unknown>> = []
|
||||
|
||||
handleKicked()
|
||||
assert.deepEqual(saved, [], '桥未就绪时不应有调用结果')
|
||||
|
||||
// 桥就绪:注入 api 并触发就绪事件,应补一次清理
|
||||
fakeWindow.pywebview = { api: { save_config: (data: Record<string, unknown>) => void saved.push(data) } }
|
||||
dispatch('pywebviewready')
|
||||
|
||||
assert.deepEqual(saved, [{ current_uid: '' }])
|
||||
})
|
||||
|
||||
test('handleKicked 网页形态(无 pywebview)不受影响', () => {
|
||||
const { localStorage, assigned } = setupWindow(loggedInStorage())
|
||||
|
||||
handleKicked()
|
||||
|
||||
assert.equal(localStorage.getItem('uid'), null)
|
||||
assert.deepEqual(assigned, ['/login?kicked=1'])
|
||||
})
|
||||
|
||||
// ---------- 拦截器接线:Java 错误信封走 HTTP 200,成功分支也必须判 ----------
|
||||
|
||||
test('响应成功分支:body.code=4011 触发下线', () => {
|
||||
const { localStorage, assigned } = setupWindow(loggedInStorage())
|
||||
const response = { data: { success: false, code: KICKED_CODE, message: '该账号已在其他设备登录,本设备已下线' } }
|
||||
|
||||
const returned = onResponseFulfilled(response as never)
|
||||
|
||||
assert.equal(returned, response)
|
||||
assert.equal(localStorage.getItem('uid'), null)
|
||||
assert.equal(localStorage.getItem('aiimage_auto_login'), '0')
|
||||
assert.deepEqual(assigned, ['/login?kicked=1'])
|
||||
})
|
||||
|
||||
test('响应失败分支:错误响应体 code=4011 触发下线', async () => {
|
||||
const { localStorage, assigned } = setupWindow(loggedInStorage())
|
||||
|
||||
await assert.rejects(
|
||||
() => onResponseRejected({ response: { data: { code: KICKED_CODE } }, message: '请求失败' }),
|
||||
/请求失败/,
|
||||
)
|
||||
|
||||
assert.equal(localStorage.getItem('uid'), null)
|
||||
assert.deepEqual(assigned, ['/login?kicked=1'])
|
||||
})
|
||||
|
||||
test('普通业务错误(403 等)不触发下线', () => {
|
||||
const { localStorage, assigned } = setupWindow(loggedInStorage())
|
||||
|
||||
onResponseFulfilled({ data: { success: false, code: 403, message: '需要管理员权限' } } as never)
|
||||
|
||||
assert.equal(localStorage.getItem('uid'), '1095')
|
||||
assert.deepEqual(assigned, [])
|
||||
})
|
||||
Reference in New Issue
Block a user