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
+23 -4
View File
@@ -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
}