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