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:
2026-09-13 23:08:22 +08:00
parent a0f6582914
commit b70557a077
53 changed files with 3982 additions and 101 deletions
@@ -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), '')
})