b70557a077
- 单设备登录:登录成功即 last-login-wins 绑定 users.machine;非超管旧 token 在下一次受保护请求抛 4011 下线,超管豁免;仅认 token 内签名 deviceId,不用请求头。 前端两端接入踢下线跳转(?kicked=1 提示),V118 清空历史 machine - 站内通知:新增 notification 模块(任务失败扫描 / 密钥欠费 / 下游服务探测三类来源), 前后台铃铛组件 + 轮询;后台列表按主管数据范围(UserDataScopeSupport)过滤;V116 建表 均已于 2026-09-13 部署上线,此次补提交源码(此前仅存在于已部署 JAR/构建产物中)
110 lines
4.5 KiB
TypeScript
110 lines
4.5 KiB
TypeScript
/** API 响应解包与错误归一化(任务 22):纯函数,无框架依赖。 */
|
||
|
||
export const REQUEST_FALLBACK_MESSAGE = '网络异常,请稍后重试'
|
||
|
||
function textOf(value: Record<string, unknown>): string {
|
||
const message = value.message
|
||
const error = value.error
|
||
if (typeof message === 'string' && message.trim()) return message.trim()
|
||
if (typeof error === 'string' && error.trim()) return error.trim()
|
||
return ''
|
||
}
|
||
|
||
/**
|
||
* 统一解包 Java 响应:
|
||
* - success=false -> 抛错(取 message/error,空则“请求失败”);
|
||
* - 带非空 data -> 返回 data;
|
||
* - 否则原样返回(允许 {item}/{items} 等信封由调用方再解)。
|
||
*/
|
||
export function unwrap<T>(payload: unknown): T {
|
||
const value = payload as Record<string, unknown> | null
|
||
if (!value || typeof value !== 'object') return payload as T
|
||
if (value.success === false) {
|
||
throw new Error(textOf(value) || '请求失败')
|
||
}
|
||
if ('data' in value && value.data !== null && value.data !== undefined) {
|
||
return value.data as T
|
||
}
|
||
return payload as T
|
||
}
|
||
|
||
/** 从任意负载取错误文案;缺省回退。 */
|
||
export function errorTextOf(payload: unknown): string {
|
||
if (payload instanceof Error) return payload.message.trim() || REQUEST_FALLBACK_MESSAGE
|
||
if (payload && typeof payload === 'object') {
|
||
const text = textOf(payload as Record<string, unknown>)
|
||
if (text) return text
|
||
}
|
||
if (typeof payload === 'string' && payload.trim()) return payload.trim()
|
||
return REQUEST_FALLBACK_MESSAGE
|
||
}
|
||
|
||
/** 负载/状态码是否 401(未登录)。 */
|
||
export function isUnauthorized(payload: unknown): boolean {
|
||
const record = payload as { status?: unknown; statusCode?: unknown; code?: unknown } | null
|
||
if (!record || typeof record !== 'object') return false
|
||
return [record.status, record.statusCode, record.code].some((v) => v === 401)
|
||
}
|
||
|
||
/** 负载/状态码是否 403(已登录但无后台权限)。 */
|
||
export function isForbidden(payload: unknown): boolean {
|
||
const record = payload as { status?: unknown; statusCode?: unknown; code?: unknown } | null
|
||
if (!record || typeof record !== 'object') return false
|
||
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
|
||
if (response) {
|
||
const text = errorTextOf(response.data)
|
||
if (text) return text
|
||
}
|
||
return errorTextOf(error)
|
||
}
|
||
|
||
/** 当前地址作为登录跳转的 redirect 参数(encodeURIComponent 后)。 */
|
||
export function loginRedirectTarget(location: { pathname: string; search: string }): string {
|
||
return encodeURIComponent(`${location.pathname}${location.search}`)
|
||
}
|
||
|
||
/** 登录页统一入口;401 一律跳此路径。 */
|
||
export const LOGIN_PATH = '/login'
|
||
|
||
/** 当前 pathname 是否处于登录页(去掉可能携带的查询串后比较)。 */
|
||
export function isLoginLocation(pathname: string): boolean {
|
||
if (typeof pathname !== 'string') return false
|
||
const queryAt = pathname.indexOf('?')
|
||
return (queryAt >= 0 ? pathname.slice(0, queryAt) : pathname) === LOGIN_PATH
|
||
}
|
||
|
||
/** 把请求 URL(相对或绝对)规整为相对路径,便于与登录入口比较。 */
|
||
export function requestRelativePath(url: string): string {
|
||
if (typeof url !== 'string') return ''
|
||
const noQuery = url.split('?')[0]
|
||
const match = noQuery.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/]+(.*)$/)
|
||
return match ? match[1] : noQuery
|
||
}
|
||
|
||
/** 401 失败请求本身是否指向登录/认证端点(登录提交失败不应再被踢回登录页)。 */
|
||
export function isAuthEndpointRequest(url: string | undefined): boolean {
|
||
return requestRelativePath(url || '') === LOGIN_PATH
|
||
}
|
||
|
||
/**
|
||
* 401 是否应执行“跳 /login”:已处于登录页或失败请求即登录端点时不应再跳,
|
||
* 否则会在登录页循环跳转或吞掉登录失败反馈。
|
||
*/
|
||
export function shouldRedirectUnauthorized(currentPathname: string, requestUrl?: string): boolean {
|
||
if (isLoginLocation(currentPathname)) return false
|
||
if (isAuthEndpointRequest(requestUrl)) return false
|
||
return true
|
||
}
|