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:
@@ -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)),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user