feat(auth): 登录态改为滑动续期(后台 24h/7d,打手 7d/30d)
This commit is contained in:
@@ -113,6 +113,11 @@ DB_BACKUP_KEEP=2
|
|||||||
|
|
||||||
# Admin
|
# Admin
|
||||||
ADMIN_SESSION_SECRET=change-me-long-random-session-secret
|
ADMIN_SESSION_SECRET=change-me-long-random-session-secret
|
||||||
|
# 会话滑动续期:不配置时后台 24h 空闲 / 7d 绝对上限,打手 7d 空闲 / 30d 绝对上限。
|
||||||
|
# ADMIN_SESSION_IDLE_TTL_HOURS=24
|
||||||
|
# ADMIN_SESSION_ABSOLUTE_TTL_HOURS=168
|
||||||
|
# WORKER_SESSION_IDLE_TTL_HOURS=168
|
||||||
|
# WORKER_SESSION_ABSOLUTE_TTL_HOURS=720
|
||||||
ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"change-me-admin-password","role":"admin"},{"username":"operator","password":"change-me-operator-password","role":"operator"}]
|
ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"change-me-admin-password","role":"admin"},{"username":"operator","password":"change-me-operator-password","role":"operator"}]
|
||||||
|
|
||||||
# 生产环境必须使用显式来源白名单,多个来源用逗号分隔。
|
# 生产环境必须使用显式来源白名单,多个来源用逗号分隔。
|
||||||
|
|||||||
@@ -67,7 +67,9 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
|
|||||||
|
|
||||||
admin: {
|
admin: {
|
||||||
sessionSecret: '',
|
sessionSecret: '',
|
||||||
sessionTtlHours: 12,
|
// 后台:24h 空闲过期,登录后最多 7 天必须重新登录。
|
||||||
|
sessionIdleTtlHours: 24,
|
||||||
|
sessionAbsoluteTtlHours: 168,
|
||||||
defaultUsers: [],
|
defaultUsers: [],
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -122,6 +124,9 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
|
|||||||
dailyLimit: 10,
|
dailyLimit: 10,
|
||||||
},
|
},
|
||||||
orderTimelineVisible: false,
|
orderTimelineVisible: false,
|
||||||
|
// 打手:7 天空闲过期,登录后最多 30 天必须重新登录。
|
||||||
|
sessionIdleTtlHours: 168,
|
||||||
|
sessionAbsoluteTtlHours: 720,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,7 +128,8 @@ function createRuntimeConfig(): RuntimeConfig {
|
|||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
sessionSecret: '',
|
sessionSecret: '',
|
||||||
sessionTtlHours: 12,
|
sessionIdleTtlHours: 24,
|
||||||
|
sessionAbsoluteTtlHours: 168,
|
||||||
defaultUsers: [],
|
defaultUsers: [],
|
||||||
},
|
},
|
||||||
platforms: {
|
platforms: {
|
||||||
@@ -177,6 +178,8 @@ function createRuntimeConfig(): RuntimeConfig {
|
|||||||
dailyLimit: 10,
|
dailyLimit: 10,
|
||||||
},
|
},
|
||||||
orderTimelineVisible: false,
|
orderTimelineVisible: false,
|
||||||
|
sessionIdleTtlHours: 168,
|
||||||
|
sessionAbsoluteTtlHours: 720,
|
||||||
},
|
},
|
||||||
cors: {
|
cors: {
|
||||||
allowedOrigins: ['*'],
|
allowedOrigins: ['*'],
|
||||||
|
|||||||
@@ -56,7 +56,12 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [
|
|||||||
stringEnv('STORAGE_OSS_REGION', ['storage', 'oss', 'region']),
|
stringEnv('STORAGE_OSS_REGION', ['storage', 'oss', 'region']),
|
||||||
integerEnv('CLAIM_TOKEN_TTL_HOURS', ['orders', 'tokenTtlHours']),
|
integerEnv('CLAIM_TOKEN_TTL_HOURS', ['orders', 'tokenTtlHours']),
|
||||||
stringEnv('ADMIN_SESSION_SECRET', ['admin', 'sessionSecret']),
|
stringEnv('ADMIN_SESSION_SECRET', ['admin', 'sessionSecret']),
|
||||||
integerEnv('ADMIN_SESSION_TTL_HOURS', ['admin', 'sessionTtlHours']),
|
// 兼容旧变量:固定 TTL 时代按“绝对上限”理解,空闲窗口仍用新默认值并被绝对上限钳制。
|
||||||
|
integerEnv('ADMIN_SESSION_TTL_HOURS', ['admin', 'sessionAbsoluteTtlHours']),
|
||||||
|
integerEnv('ADMIN_SESSION_IDLE_TTL_HOURS', ['admin', 'sessionIdleTtlHours']),
|
||||||
|
integerEnv('ADMIN_SESSION_ABSOLUTE_TTL_HOURS', ['admin', 'sessionAbsoluteTtlHours']),
|
||||||
|
integerEnv('WORKER_SESSION_IDLE_TTL_HOURS', ['worker', 'sessionIdleTtlHours']),
|
||||||
|
integerEnv('WORKER_SESSION_ABSOLUTE_TTL_HOURS', ['worker', 'sessionAbsoluteTtlHours']),
|
||||||
adminUsersJsonEnv('ADMIN_DEFAULT_USERS_JSON', ['admin', 'defaultUsers']),
|
adminUsersJsonEnv('ADMIN_DEFAULT_USERS_JSON', ['admin', 'defaultUsers']),
|
||||||
stringEnv('KAQUAN91_USER_ID', ['platforms', 'ninetyone', 'userId']),
|
stringEnv('KAQUAN91_USER_ID', ['platforms', 'ninetyone', 'userId']),
|
||||||
stringEnv('KAQUAN91_SECRET', ['platforms', 'ninetyone', 'secret']),
|
stringEnv('KAQUAN91_SECRET', ['platforms', 'ninetyone', 'secret']),
|
||||||
|
|||||||
@@ -192,7 +192,8 @@ function createRuntimeConfig(overrides: Record<string, unknown> = {}): RuntimeCo
|
|||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
sessionSecret: '0123456789abcdef0123456789abcdef',
|
sessionSecret: '0123456789abcdef0123456789abcdef',
|
||||||
sessionTtlHours: 12,
|
sessionIdleTtlHours: 24,
|
||||||
|
sessionAbsoluteTtlHours: 168,
|
||||||
defaultUsers: [
|
defaultUsers: [
|
||||||
{
|
{
|
||||||
username: 'admin',
|
username: 'admin',
|
||||||
@@ -247,6 +248,8 @@ function createRuntimeConfig(overrides: Record<string, unknown> = {}): RuntimeCo
|
|||||||
dailyLimit: 10,
|
dailyLimit: 10,
|
||||||
},
|
},
|
||||||
orderTimelineVisible: false,
|
orderTimelineVisible: false,
|
||||||
|
sessionIdleTtlHours: 168,
|
||||||
|
sessionAbsoluteTtlHours: 720,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
overrides,
|
overrides,
|
||||||
|
|||||||
@@ -97,7 +97,16 @@ export function validateRuntimeConfig(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
requireInteger(issues, 'orders.tokenTtlHours', config.orders?.tokenTtlHours, { min: 0 })
|
requireInteger(issues, 'orders.tokenTtlHours', config.orders?.tokenTtlHours, { min: 0 })
|
||||||
requireInteger(issues, 'admin.sessionTtlHours', config.admin?.sessionTtlHours, { min: 1 })
|
requireInteger(issues, 'admin.sessionIdleTtlHours', config.admin?.sessionIdleTtlHours, { min: 1 })
|
||||||
|
requireInteger(issues, 'admin.sessionAbsoluteTtlHours', config.admin?.sessionAbsoluteTtlHours, {
|
||||||
|
min: 1,
|
||||||
|
})
|
||||||
|
requireInteger(issues, 'worker.sessionIdleTtlHours', config.worker?.sessionIdleTtlHours, {
|
||||||
|
min: 1,
|
||||||
|
})
|
||||||
|
requireInteger(issues, 'worker.sessionAbsoluteTtlHours', config.worker?.sessionAbsoluteTtlHours, {
|
||||||
|
min: 1,
|
||||||
|
})
|
||||||
|
|
||||||
validateOptionalHttpUrl(issues, 'orders.claimBaseUrl', config.orders?.claimBaseUrl)
|
validateOptionalHttpUrl(issues, 'orders.claimBaseUrl', config.orders?.claimBaseUrl)
|
||||||
validateOptionalStorageEndpoint(issues, 'storage.endpoint', config.storage?.endpoint)
|
validateOptionalStorageEndpoint(issues, 'storage.endpoint', config.storage?.endpoint)
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ export function createCorsMiddleware(
|
|||||||
'Content-Type, Authorization, X-Shop-Token, X-Poll-Token',
|
'Content-Type, Authorization, X-Shop-Token, X-Poll-Token',
|
||||||
)
|
)
|
||||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS')
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS')
|
||||||
|
// 前端靠这两个响应头同步滑动续期后的到期时间。
|
||||||
|
res.setHeader(
|
||||||
|
'Access-Control-Expose-Headers',
|
||||||
|
'X-Session-Expires-At, X-Session-Absolute-Expires-At',
|
||||||
|
)
|
||||||
res.setHeader('Access-Control-Max-Age', '86400')
|
res.setHeader('Access-Control-Max-Age', '86400')
|
||||||
|
|
||||||
if (req.method === 'OPTIONS') {
|
if (req.method === 'OPTIONS') {
|
||||||
|
|||||||
@@ -60,16 +60,47 @@ export async function getActiveAdminSession(
|
|||||||
return result.rows[0] || null
|
return result.rows[0] || null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function touchAdminSession(sessionId: string, now: string): Promise<boolean> {
|
/** 活跃请求的写入节流:last_seen_at 与滑动过期最多 5 分钟刷新一次。 */
|
||||||
const result = await query(
|
const ADMIN_SESSION_TOUCH_MIN_INTERVAL = '5 minutes'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验会话有效并滑动续期。
|
||||||
|
* 返回续期后的空闲到期时间;会话无效/已过期返回 null。
|
||||||
|
*/
|
||||||
|
export async function touchAdminSession(input: {
|
||||||
|
sessionId: string
|
||||||
|
now: string
|
||||||
|
idleExpiresAt: string
|
||||||
|
absoluteExpiresAt: string
|
||||||
|
}): Promise<string | null> {
|
||||||
|
const result = await query<{ expires_at: string | Date }>(
|
||||||
`
|
`
|
||||||
UPDATE admin_sessions
|
WITH target AS (
|
||||||
SET last_seen_at = $2, updated_at = $2
|
SELECT session_id, expires_at
|
||||||
WHERE session_id = $1 AND status = 'active' AND expires_at > $2
|
FROM admin_sessions
|
||||||
|
WHERE session_id = $1 AND status = 'active' AND expires_at > $2
|
||||||
|
), touched AS (
|
||||||
|
UPDATE admin_sessions s
|
||||||
|
SET last_seen_at = $2,
|
||||||
|
updated_at = $2,
|
||||||
|
expires_at = LEAST($3::timestamptz, $4::timestamptz)
|
||||||
|
FROM target
|
||||||
|
WHERE s.session_id = target.session_id
|
||||||
|
AND s.last_seen_at < ($2::timestamptz - INTERVAL '${ADMIN_SESSION_TOUCH_MIN_INTERVAL}')
|
||||||
|
AND s.expires_at < LEAST($3::timestamptz, $4::timestamptz)
|
||||||
|
RETURNING s.expires_at
|
||||||
|
)
|
||||||
|
SELECT COALESCE(
|
||||||
|
(SELECT expires_at FROM touched),
|
||||||
|
(SELECT expires_at FROM target)
|
||||||
|
) AS expires_at
|
||||||
`,
|
`,
|
||||||
[sessionId, now],
|
[input.sessionId, input.now, input.idleExpiresAt, input.absoluteExpiresAt],
|
||||||
)
|
)
|
||||||
return result.rowCount === 1
|
const expiresAt = result.rows[0]?.expires_at
|
||||||
|
if (!expiresAt) return null
|
||||||
|
const parsed = new Date(expiresAt).getTime()
|
||||||
|
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function revokeAdminSession(sessionId: string, now: string): Promise<boolean> {
|
export async function revokeAdminSession(sessionId: string, now: string): Promise<boolean> {
|
||||||
|
|||||||
@@ -84,40 +84,49 @@ export async function getWorkerSessionBySessionId(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验会话仍然有效,并顺带刷新 last_seen_at。
|
* 校验会话仍然有效,并滑动续期空闲窗口。
|
||||||
*
|
*
|
||||||
* 这里必须同时满足两个约束,所以不能用"直接跳过"的写法:
|
* 约束:
|
||||||
* 1. 返回值是**鉴权有效性**——上层 `!(await touchWorkerSession(...))` 为真就直接 401
|
* 1. 返回值是鉴权有效性——null 表示会话已失效/被删除,上层直接 401;
|
||||||
* 「登录设备已被删除」,跳过查询会把所有正常请求打成未登录;
|
* 2. 写入要节流:32 天里 last_seen_at 被写了 3300 万次,占数据库总执行时间的 2.8%;
|
||||||
* 2. 也不能像以前那样每个请求都真写一次:32 天里 last_seen_at 被写了 3300 万次,
|
* 3. 续期后的 expires_at 不得超过 Token 里的绝对上限,绝对上限只能重新登录突破。
|
||||||
* 占数据库总执行时间的 2.8%,还在 worker_sessions 上堆出 14% 的死元组。
|
|
||||||
*
|
|
||||||
* 因此改成「一条语句拿到有效性 + 只在超过 5 分钟没刷新时才写」。
|
|
||||||
* 副作用只有 last_seen_at 精度变粗(用于设备列表排序与展示),
|
|
||||||
* 不影响会话有效期:expires_at 在建会话时确定,不做滑动续期。
|
|
||||||
*/
|
*/
|
||||||
const WORKER_SESSION_TOUCH_MIN_INTERVAL = '5 minutes'
|
const WORKER_SESSION_TOUCH_MIN_INTERVAL = '5 minutes'
|
||||||
|
|
||||||
export async function touchWorkerSession(sessionId: string, now: string): Promise<boolean> {
|
export async function touchWorkerSession(input: {
|
||||||
const result = await query<{ valid: boolean }>(
|
sessionId: string
|
||||||
|
now: string
|
||||||
|
idleExpiresAt: string
|
||||||
|
absoluteExpiresAt: string
|
||||||
|
}): Promise<string | null> {
|
||||||
|
const result = await query<{ expires_at: string | Date }>(
|
||||||
`
|
`
|
||||||
WITH target AS (
|
WITH target AS (
|
||||||
SELECT session_id
|
SELECT session_id, expires_at
|
||||||
FROM worker_sessions
|
FROM worker_sessions
|
||||||
WHERE session_id = $1 AND status = 'active' AND expires_at > $2
|
WHERE session_id = $1 AND status = 'active' AND expires_at > $2
|
||||||
), touched AS (
|
), touched AS (
|
||||||
UPDATE worker_sessions ws
|
UPDATE worker_sessions ws
|
||||||
SET last_seen_at = $2, updated_at = $2
|
SET last_seen_at = $2,
|
||||||
|
updated_at = $2,
|
||||||
|
expires_at = LEAST($3::timestamptz, $4::timestamptz)
|
||||||
FROM target
|
FROM target
|
||||||
WHERE ws.session_id = target.session_id
|
WHERE ws.session_id = target.session_id
|
||||||
AND ws.last_seen_at < ($2::timestamptz - INTERVAL '${WORKER_SESSION_TOUCH_MIN_INTERVAL}')
|
AND ws.last_seen_at < ($2::timestamptz - INTERVAL '${WORKER_SESSION_TOUCH_MIN_INTERVAL}')
|
||||||
RETURNING ws.session_id
|
AND ws.expires_at < LEAST($3::timestamptz, $4::timestamptz)
|
||||||
|
RETURNING ws.expires_at
|
||||||
)
|
)
|
||||||
SELECT EXISTS (SELECT 1 FROM target) AS valid
|
SELECT COALESCE(
|
||||||
|
(SELECT expires_at FROM touched),
|
||||||
|
(SELECT expires_at FROM target)
|
||||||
|
) AS expires_at
|
||||||
`,
|
`,
|
||||||
[String(sessionId || '').trim(), now],
|
[input.sessionId, input.now, input.idleExpiresAt, input.absoluteExpiresAt],
|
||||||
)
|
)
|
||||||
return Boolean(result.rows[0]?.valid)
|
const expiresAt = result.rows[0]?.expires_at
|
||||||
|
if (!expiresAt) return null
|
||||||
|
const parsed = new Date(expiresAt).getTime()
|
||||||
|
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function revokeWorkerSession(
|
export async function revokeWorkerSession(
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ router.post(
|
|||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/auth/session',
|
'/auth/session',
|
||||||
createJsonHandler((req) => getAdminSessionSummary(extractBearerToken(req)), {
|
createJsonHandler((req, res) => getAdminSessionSummary(extractBearerToken(req), res), {
|
||||||
successMessage: 'ok',
|
successMessage: 'ok',
|
||||||
errorMessage: '读取后台登录态失败',
|
errorMessage: '读取后台登录态失败',
|
||||||
scope: '[admin/auth/session]',
|
scope: '[admin/auth/session]',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from '../../services/admin/admin-auth-service.js'
|
} from '../../services/admin/admin-auth-service.js'
|
||||||
import { writeAdminAuditLog } from '../../services/admin/admin-audit-service.js'
|
import { writeAdminAuditLog } from '../../services/admin/admin-audit-service.js'
|
||||||
import { buildSuccessPayload, createHttpError, sendRouteError } from '../../utils/http.js'
|
import { buildSuccessPayload, createHttpError, sendRouteError } from '../../utils/http.js'
|
||||||
|
import { setSessionExpiryHeaders } from '../../utils/session-headers.js'
|
||||||
import { logWarn } from '../../utils/logger.js'
|
import { logWarn } from '../../utils/logger.js'
|
||||||
import type { AdminSession } from '../../services/admin/admin-auth-service.js'
|
import type { AdminSession } from '../../services/admin/admin-auth-service.js'
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ export async function requireAdminSession(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
req.adminSession = await verifyAdminSessionToken(extractBearerToken(req))
|
req.adminSession = await verifyAdminSessionToken(extractBearerToken(req))
|
||||||
|
setSessionExpiryHeaders(res, req.adminSession)
|
||||||
next()
|
next()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sendRouteError(res, error, '后台鉴权失败', '[admin/auth]')
|
sendRouteError(res, error, '后台鉴权失败', '[admin/auth]')
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ router.post(
|
|||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/auth/session',
|
'/auth/session',
|
||||||
createRouteHandler((req) => getWorkerSessionSummary(extractWorkerBearerToken(req)), {
|
createRouteHandler((req, res) => getWorkerSessionSummary(extractWorkerBearerToken(req), res), {
|
||||||
successMessage: 'ok',
|
successMessage: 'ok',
|
||||||
errorMessage: '读取接单登录态失败',
|
errorMessage: '读取接单登录态失败',
|
||||||
scope: '[worker/auth/session]',
|
scope: '[worker/auth/session]',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type WorkerSession,
|
type WorkerSession,
|
||||||
} from '../../services/worker-platform/index.js'
|
} from '../../services/worker-platform/index.js'
|
||||||
import { createHttpError, sendRouteError } from '../../utils/http.js'
|
import { createHttpError, sendRouteError } from '../../utils/http.js'
|
||||||
|
import { setSessionExpiryHeaders } from '../../utils/session-headers.js'
|
||||||
|
|
||||||
export async function requireWorkerSession(
|
export async function requireWorkerSession(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -14,6 +15,7 @@ export async function requireWorkerSession(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
req.workerSession = await verifyWorkerSessionToken(extractWorkerBearerToken(req))
|
req.workerSession = await verifyWorkerSessionToken(extractWorkerBearerToken(req))
|
||||||
|
setSessionExpiryHeaders(res, req.workerSession)
|
||||||
next()
|
next()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sendRouteError(res, error, '接单端鉴权失败', '[worker/auth]')
|
sendRouteError(res, error, '接单端鉴权失败', '[worker/auth]')
|
||||||
|
|||||||
@@ -18,7 +18,12 @@ import {
|
|||||||
revokeAdminSession,
|
revokeAdminSession,
|
||||||
touchAdminSession,
|
touchAdminSession,
|
||||||
} from '../../repositories/admin-session-repo.js'
|
} from '../../repositories/admin-session-repo.js'
|
||||||
import { addHours, nowIso } from '../../utils/time.js'
|
import { nowIso } from '../../utils/time.js'
|
||||||
|
import {
|
||||||
|
resolveSessionExpiryWindow,
|
||||||
|
resolveSlidingSessionExpiresAt,
|
||||||
|
} from '../../utils/session-window.js'
|
||||||
|
import { setSessionExpiryHeaders } from '../../utils/session-headers.js'
|
||||||
import { createHttpError } from '../../utils/http.js'
|
import { createHttpError } from '../../utils/http.js'
|
||||||
import { normalizePage, normalizePageSize } from './admin-query-utils.js'
|
import { normalizePage, normalizePageSize } from './admin-query-utils.js'
|
||||||
import { recordAdminLoginLog } from './admin-login-log-service.js'
|
import { recordAdminLoginLog } from './admin-login-log-service.js'
|
||||||
@@ -39,6 +44,7 @@ export type AdminSession = {
|
|||||||
displayName: string
|
displayName: string
|
||||||
role: AdminRole
|
role: AdminRole
|
||||||
expiresAt: string
|
expiresAt: string
|
||||||
|
absoluteExpiresAt: string
|
||||||
sessionVersion: number
|
sessionVersion: number
|
||||||
deviceId: string
|
deviceId: string
|
||||||
permissions: string[]
|
permissions: string[]
|
||||||
@@ -346,8 +352,8 @@ export async function verifyAdminSessionToken(token: unknown): Promise<AdminSess
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const expiresAt = String(payload?.exp || '').trim()
|
const absoluteExpiresAt = String(payload?.exp || '').trim()
|
||||||
if (!expiresAt || Date.parse(expiresAt) <= Date.now()) {
|
if (!absoluteExpiresAt || Date.parse(absoluteExpiresAt) <= Date.now()) {
|
||||||
throw createHttpError('后台登录已过期,请重新登录', {
|
throw createHttpError('后台登录已过期,请重新登录', {
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
errorCode: 'admin_auth_expired',
|
errorCode: 'admin_auth_expired',
|
||||||
@@ -362,14 +368,30 @@ export async function verifyAdminSessionToken(token: unknown): Promise<AdminSess
|
|||||||
errorCode: 'admin_auth_invalid',
|
errorCode: 'admin_auth_invalid',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const activeSession = await getActiveAdminSession(sessionId, nowIso())
|
const now = nowIso()
|
||||||
|
const activeSession = await getActiveAdminSession(sessionId, now)
|
||||||
if (!activeSession || Number(activeSession.user_id) !== userId) {
|
if (!activeSession || Number(activeSession.user_id) !== userId) {
|
||||||
throw createHttpError('后台登录态已失效,请重新登录', {
|
throw createHttpError('后台登录态已失效,请重新登录', {
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
errorCode: 'admin_auth_revoked',
|
errorCode: 'admin_auth_revoked',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
await touchAdminSession(sessionId, nowIso())
|
const touchedExpiresAt = await touchAdminSession({
|
||||||
|
sessionId,
|
||||||
|
now,
|
||||||
|
idleExpiresAt: resolveSlidingSessionExpiresAt({
|
||||||
|
now,
|
||||||
|
idleHours: runtimeConfig.admin.sessionIdleTtlHours,
|
||||||
|
absoluteExpiresAt,
|
||||||
|
}),
|
||||||
|
absoluteExpiresAt,
|
||||||
|
})
|
||||||
|
if (!touchedExpiresAt) {
|
||||||
|
throw createHttpError('后台登录态已失效,请重新登录', {
|
||||||
|
statusCode: 401,
|
||||||
|
errorCode: 'admin_auth_revoked',
|
||||||
|
})
|
||||||
|
}
|
||||||
const user = await getAdminUserById(userId)
|
const user = await getAdminUserById(userId)
|
||||||
if (!user || user.status !== 'active') {
|
if (!user || user.status !== 'active') {
|
||||||
throw createHttpError('后台账号已不可用,请重新登录', {
|
throw createHttpError('后台账号已不可用,请重新登录', {
|
||||||
@@ -393,7 +415,8 @@ export async function verifyAdminSessionToken(token: unknown): Promise<AdminSess
|
|||||||
username: String(user.username || ''),
|
username: String(user.username || ''),
|
||||||
displayName: String(user.display_name || user.username || ''),
|
displayName: String(user.display_name || user.username || ''),
|
||||||
role: normalizeAdminRole(user.role),
|
role: normalizeAdminRole(user.role),
|
||||||
expiresAt,
|
expiresAt: touchedExpiresAt,
|
||||||
|
absoluteExpiresAt,
|
||||||
sessionVersion: currentSessionVersion,
|
sessionVersion: currentSessionVersion,
|
||||||
deviceId: String(activeSession.device_id || ''),
|
deviceId: String(activeSession.device_id || ''),
|
||||||
permissions: await listAdminPermissionKeysForRole(normalizeAdminRole(user.role)),
|
permissions: await listAdminPermissionKeysForRole(normalizeAdminRole(user.role)),
|
||||||
@@ -406,12 +429,17 @@ export async function logoutAdmin(token: unknown): Promise<{ success: boolean; r
|
|||||||
return { success: true, revoked }
|
return { success: true, revoked }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAdminSessionSummary(token: unknown): Promise<JsonObject> {
|
export async function getAdminSessionSummary(
|
||||||
|
token: unknown,
|
||||||
|
res?: { setHeader(name: string, value: string): unknown },
|
||||||
|
): Promise<JsonObject> {
|
||||||
const session = await verifyAdminSessionToken(token)
|
const session = await verifyAdminSessionToken(token)
|
||||||
|
if (res) setSessionExpiryHeaders(res, session)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
expiresAt: session.expiresAt,
|
expiresAt: session.expiresAt,
|
||||||
|
absoluteExpiresAt: session.absoluteExpiresAt,
|
||||||
user: {
|
user: {
|
||||||
userId: session.userId,
|
userId: session.userId,
|
||||||
username: session.username,
|
username: session.username,
|
||||||
@@ -738,7 +766,12 @@ export function ensureAdminAuthConfigured(): void {
|
|||||||
|
|
||||||
function createAdminSession(user: AdminUserRow, deviceId = ''): JsonObject {
|
function createAdminSession(user: AdminUserRow, deviceId = ''): JsonObject {
|
||||||
const issuedAt = nowIso()
|
const issuedAt = nowIso()
|
||||||
const expiresAt = addHours(issuedAt, Number(runtimeConfig.admin.sessionTtlHours || 12))
|
// Token 的 exp 是绝对上限;DB 会话的 expires_at 是空闲窗口,会在每次访问时滑动。
|
||||||
|
const { idleExpiresAt, absoluteExpiresAt } = resolveSessionExpiryWindow({
|
||||||
|
now: issuedAt,
|
||||||
|
idleHours: Number(runtimeConfig.admin.sessionIdleTtlHours || 24),
|
||||||
|
absoluteHours: Number(runtimeConfig.admin.sessionAbsoluteTtlHours || 168),
|
||||||
|
})
|
||||||
const payload = {
|
const payload = {
|
||||||
sid: crypto.randomBytes(12).toString('hex'),
|
sid: crypto.randomBytes(12).toString('hex'),
|
||||||
uid: Number(user.id),
|
uid: Number(user.id),
|
||||||
@@ -747,7 +780,7 @@ function createAdminSession(user: AdminUserRow, deviceId = ''): JsonObject {
|
|||||||
dev: String(deviceId || ''),
|
dev: String(deviceId || ''),
|
||||||
ver: normalizeAdminSessionVersion(user.session_version),
|
ver: normalizeAdminSessionVersion(user.session_version),
|
||||||
iat: issuedAt,
|
iat: issuedAt,
|
||||||
exp: expiresAt,
|
exp: absoluteExpiresAt,
|
||||||
}
|
}
|
||||||
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
||||||
const signature = signPayload(encodedPayload)
|
const signature = signPayload(encodedPayload)
|
||||||
@@ -756,7 +789,8 @@ function createAdminSession(user: AdminUserRow, deviceId = ''): JsonObject {
|
|||||||
sessionId: payload.sid,
|
sessionId: payload.sid,
|
||||||
issuedAt,
|
issuedAt,
|
||||||
token: `${encodedPayload}.${signature}`,
|
token: `${encodedPayload}.${signature}`,
|
||||||
expiresAt,
|
expiresAt: idleExpiresAt,
|
||||||
|
absoluteExpiresAt,
|
||||||
user: {
|
user: {
|
||||||
userId: Number(user.id),
|
userId: Number(user.id),
|
||||||
username: String(user.username || ''),
|
username: String(user.username || ''),
|
||||||
|
|||||||
@@ -3,12 +3,18 @@ import crypto from 'node:crypto'
|
|||||||
import { runtimeConfig } from '../../config/runtime.js'
|
import { runtimeConfig } from '../../config/runtime.js'
|
||||||
import type { WorkerUserRow } from '../../repositories/worker-platform/index.js'
|
import type { WorkerUserRow } from '../../repositories/worker-platform/index.js'
|
||||||
import { createHttpError } from '../../utils/http.js'
|
import { createHttpError } from '../../utils/http.js'
|
||||||
import { addHours, nowIso } from '../../utils/time.js'
|
import { nowIso } from '../../utils/time.js'
|
||||||
|
import { resolveSessionExpiryWindow } from '../../utils/session-window.js'
|
||||||
import { normalizeSessionVersion } from './worker-normalizers.js'
|
import { normalizeSessionVersion } from './worker-normalizers.js'
|
||||||
|
|
||||||
export function createWorkerSessionToken(worker: WorkerUserRow) {
|
export function createWorkerSessionToken(worker: WorkerUserRow) {
|
||||||
const issuedAt = nowIso()
|
const issuedAt = nowIso()
|
||||||
const expiresAt = addHours(issuedAt, Number(runtimeConfig.admin?.sessionTtlHours || 12))
|
// Token 的 exp 是绝对上限;DB 会话的 expires_at 是空闲窗口,会在每次访问时滑动。
|
||||||
|
const { idleExpiresAt, absoluteExpiresAt } = resolveSessionExpiryWindow({
|
||||||
|
now: issuedAt,
|
||||||
|
idleHours: Number(runtimeConfig.worker?.sessionIdleTtlHours || 168),
|
||||||
|
absoluteHours: Number(runtimeConfig.worker?.sessionAbsoluteTtlHours || 720),
|
||||||
|
})
|
||||||
const payload = {
|
const payload = {
|
||||||
typ: 'worker',
|
typ: 'worker',
|
||||||
sid: crypto.randomBytes(12).toString('hex'),
|
sid: crypto.randomBytes(12).toString('hex'),
|
||||||
@@ -17,13 +23,14 @@ export function createWorkerSessionToken(worker: WorkerUserRow) {
|
|||||||
status: worker.status,
|
status: worker.status,
|
||||||
ver: normalizeSessionVersion(worker.session_version),
|
ver: normalizeSessionVersion(worker.session_version),
|
||||||
iat: issuedAt,
|
iat: issuedAt,
|
||||||
exp: expiresAt,
|
exp: absoluteExpiresAt,
|
||||||
}
|
}
|
||||||
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
||||||
return {
|
return {
|
||||||
sessionId: payload.sid,
|
sessionId: payload.sid,
|
||||||
token: `${encodedPayload}.${signWorkerPayload(encodedPayload)}`,
|
token: `${encodedPayload}.${signWorkerPayload(encodedPayload)}`,
|
||||||
expiresAt,
|
expiresAt: idleExpiresAt,
|
||||||
|
absoluteExpiresAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ function createCredentialSession(worker: WorkerUserRow): WorkerSession {
|
|||||||
username: worker.username,
|
username: worker.username,
|
||||||
status: worker.status,
|
status: worker.status,
|
||||||
expiresAt: '',
|
expiresAt: '',
|
||||||
|
absoluteExpiresAt: '',
|
||||||
sessionVersion: normalizeSessionVersion(worker.session_version),
|
sessionVersion: normalizeSessionVersion(worker.session_version),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2828,8 +2828,12 @@ test('会话 touch 必须同时保留鉴权语义与写入节流', async () => {
|
|||||||
src.indexOf('export async function touchWorkerSession'),
|
src.indexOf('export async function touchWorkerSession'),
|
||||||
src.indexOf('export async function revokeWorkerSession'),
|
src.indexOf('export async function revokeWorkerSession'),
|
||||||
)
|
)
|
||||||
// 返回值是鉴权有效性:上层 !(await touch) 会直接 401,不能因为"不写"就返回 false
|
// 返回值是续期后的空闲到期时间:null 才会被上层判成 401
|
||||||
assert.ok(body.includes('EXISTS (SELECT 1 FROM target) AS valid'), '必须仍然返回会话是否有效')
|
assert.ok(body.includes('RETURNING ws.expires_at'), '必须返回滑动续期后的 expires_at')
|
||||||
|
assert.ok(
|
||||||
|
body.includes('LEAST($3::timestamptz, $4::timestamptz)'),
|
||||||
|
'滑动续期不能突破 Token 里的绝对上限',
|
||||||
|
)
|
||||||
assert.ok(body.includes("status = 'active'"), '有效性判定不能丢')
|
assert.ok(body.includes("status = 'active'"), '有效性判定不能丢')
|
||||||
// 写操作必须节流:32 天里被写了 3300 万次
|
// 写操作必须节流:32 天里被写了 3300 万次
|
||||||
assert.ok(body.includes('WORKER_SESSION_TOUCH_MIN_INTERVAL'), '缺少节流间隔常量')
|
assert.ok(body.includes('WORKER_SESSION_TOUCH_MIN_INTERVAL'), '缺少节流间隔常量')
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import {
|
|||||||
revokeWorkerSession,
|
revokeWorkerSession,
|
||||||
touchWorkerSession,
|
touchWorkerSession,
|
||||||
} from '../../repositories/worker-platform/index.js'
|
} from '../../repositories/worker-platform/index.js'
|
||||||
|
import { runtimeConfig } from '../../config/runtime.js'
|
||||||
import type { JsonObject } from '../../types/json.js'
|
import type { JsonObject } from '../../types/json.js'
|
||||||
import { createHttpError } from '../../utils/http.js'
|
import { createHttpError } from '../../utils/http.js'
|
||||||
import { nowIso } from '../../utils/time.js'
|
import { nowIso } from '../../utils/time.js'
|
||||||
|
import { resolveSlidingSessionExpiresAt } from '../../utils/session-window.js'
|
||||||
|
import { setSessionExpiryHeaders } from '../../utils/session-headers.js'
|
||||||
import {
|
import {
|
||||||
ensureWorkerAuthConfigured,
|
ensureWorkerAuthConfigured,
|
||||||
isIgnorableWorkerAuthError,
|
isIgnorableWorkerAuthError,
|
||||||
@@ -102,8 +105,8 @@ export async function verifyWorkerSessionToken(token: unknown): Promise<WorkerSe
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const expiresAt = String(payload.exp || '').trim()
|
const absoluteExpiresAt = String(payload.exp || '').trim()
|
||||||
if (!expiresAt || Date.parse(expiresAt) <= Date.now()) {
|
if (!absoluteExpiresAt || Date.parse(absoluteExpiresAt) <= Date.now()) {
|
||||||
throw createHttpError('登录已过期,请重新登录', {
|
throw createHttpError('登录已过期,请重新登录', {
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
errorCode: 'worker_auth_expired',
|
errorCode: 'worker_auth_expired',
|
||||||
@@ -133,8 +136,7 @@ export async function verifyWorkerSessionToken(token: unknown): Promise<WorkerSe
|
|||||||
!sessionRecord ||
|
!sessionRecord ||
|
||||||
Number(sessionRecord.worker_id) !== Number(worker.id) ||
|
Number(sessionRecord.worker_id) !== Number(worker.id) ||
|
||||||
sessionRecord.status !== 'active' ||
|
sessionRecord.status !== 'active' ||
|
||||||
Date.parse(String(sessionRecord.expires_at || '')) <= Date.now() ||
|
Date.parse(String(sessionRecord.expires_at || '')) <= Date.now()
|
||||||
!(await touchWorkerSession(sessionId, nowIso()))
|
|
||||||
) {
|
) {
|
||||||
throw createHttpError('登录设备已被删除,请重新登录', {
|
throw createHttpError('登录设备已被删除,请重新登录', {
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
@@ -142,12 +144,31 @@ export async function verifyWorkerSessionToken(token: unknown): Promise<WorkerSe
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nowAt = nowIso()
|
||||||
|
const touchedExpiresAt = await touchWorkerSession({
|
||||||
|
sessionId,
|
||||||
|
now: nowAt,
|
||||||
|
idleExpiresAt: resolveSlidingSessionExpiresAt({
|
||||||
|
now: nowAt,
|
||||||
|
idleHours: runtimeConfig.worker?.sessionIdleTtlHours || 168,
|
||||||
|
absoluteExpiresAt,
|
||||||
|
}),
|
||||||
|
absoluteExpiresAt,
|
||||||
|
})
|
||||||
|
if (!touchedExpiresAt) {
|
||||||
|
throw createHttpError('登录设备已被删除,请重新登录', {
|
||||||
|
statusCode: 401,
|
||||||
|
errorCode: 'worker_auth_device_revoked',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const verifiedSession: WorkerSession = {
|
const verifiedSession: WorkerSession = {
|
||||||
sessionId,
|
sessionId,
|
||||||
workerId: Number(worker.id),
|
workerId: Number(worker.id),
|
||||||
username: worker.username,
|
username: worker.username,
|
||||||
status: worker.status,
|
status: worker.status,
|
||||||
expiresAt,
|
expiresAt: touchedExpiresAt,
|
||||||
|
absoluteExpiresAt,
|
||||||
sessionVersion: currentSessionVersion,
|
sessionVersion: currentSessionVersion,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,12 +221,17 @@ export async function deleteWorkerSession(session: WorkerSession, targetSessionI
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getWorkerSessionSummary(token: unknown) {
|
export async function getWorkerSessionSummary(
|
||||||
|
token: unknown,
|
||||||
|
res?: { setHeader(name: string, value: string): unknown },
|
||||||
|
) {
|
||||||
const session = await verifyWorkerSessionToken(token)
|
const session = await verifyWorkerSessionToken(token)
|
||||||
|
if (res) setSessionExpiryHeaders(res, session)
|
||||||
const worker = await getRequiredWorker(session.workerId)
|
const worker = await getRequiredWorker(session.workerId)
|
||||||
return {
|
return {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
expiresAt: session.expiresAt,
|
expiresAt: session.expiresAt,
|
||||||
|
absoluteExpiresAt: session.absoluteExpiresAt,
|
||||||
worker: mapWorkerUser(worker),
|
worker: mapWorkerUser(worker),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type WorkerSession = {
|
|||||||
username: string
|
username: string
|
||||||
status: string
|
status: string
|
||||||
expiresAt: string
|
expiresAt: string
|
||||||
|
absoluteExpiresAt: string
|
||||||
sessionVersion: number
|
sessionVersion: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,10 @@ export type RuntimeConfig = {
|
|||||||
}
|
}
|
||||||
admin: {
|
admin: {
|
||||||
sessionSecret: string
|
sessionSecret: string
|
||||||
sessionTtlHours: number
|
/** 后台空闲多久未访问即失效(每次访问滑动续期)。 */
|
||||||
|
sessionIdleTtlHours: number
|
||||||
|
/** 后台会话从登录起算的绝对上限,滑动续期不能突破。 */
|
||||||
|
sessionAbsoluteTtlHours: number
|
||||||
defaultUsers: AdminDefaultUser[]
|
defaultUsers: AdminDefaultUser[]
|
||||||
}
|
}
|
||||||
platforms: {
|
platforms: {
|
||||||
@@ -117,6 +120,10 @@ export type RuntimeConfig = {
|
|||||||
}
|
}
|
||||||
/** 打手端订单详情是否展示流转记录(默认关闭,仅后台可见) */
|
/** 打手端订单详情是否展示流转记录(默认关闭,仅后台可见) */
|
||||||
orderTimelineVisible: boolean
|
orderTimelineVisible: boolean
|
||||||
|
/** 打手空闲多久未访问即失效(每次访问滑动续期)。 */
|
||||||
|
sessionIdleTtlHours: number
|
||||||
|
/** 打手会话从登录起算的绝对上限,滑动续期不能突破。 */
|
||||||
|
sessionAbsoluteTtlHours: number
|
||||||
}
|
}
|
||||||
cors: {
|
cors: {
|
||||||
allowedOrigins: string[]
|
allowedOrigins: string[]
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
type HeaderWriter = {
|
||||||
|
setHeader(name: string, value: string): unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionExpiryLike = {
|
||||||
|
expiresAt?: string | null
|
||||||
|
absoluteExpiresAt?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把滑动续期后的空闲到期时间回传给前端。
|
||||||
|
* Token 本身不重签(exp 是绝对上限),前端只更新本地空闲计时器。
|
||||||
|
*/
|
||||||
|
export function setSessionExpiryHeaders(res: HeaderWriter, session: SessionExpiryLike): void {
|
||||||
|
if (session.expiresAt) {
|
||||||
|
res.setHeader('X-Session-Expires-At', session.expiresAt)
|
||||||
|
}
|
||||||
|
if (session.absoluteExpiresAt) {
|
||||||
|
res.setHeader('X-Session-Absolute-Expires-At', session.absoluteExpiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { resolveSessionExpiryWindow, resolveSlidingSessionExpiresAt } from './session-window.js'
|
||||||
|
import { setSessionExpiryHeaders } from './session-headers.js'
|
||||||
|
|
||||||
|
test('登录时同时给出空闲到期与绝对到期(后台 24h / 7d)', () => {
|
||||||
|
const window = resolveSessionExpiryWindow({
|
||||||
|
now: '2026-10-25T00:00:00.000Z',
|
||||||
|
idleHours: 24,
|
||||||
|
absoluteHours: 168,
|
||||||
|
})
|
||||||
|
assert.equal(window.idleExpiresAt, '2026-10-26T00:00:00.000Z')
|
||||||
|
assert.equal(window.absoluteExpiresAt, '2026-11-01T00:00:00.000Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('登录时同时给出空闲到期与绝对到期(打手 7d / 30d)', () => {
|
||||||
|
const window = resolveSessionExpiryWindow({
|
||||||
|
now: '2026-10-25T00:00:00.000Z',
|
||||||
|
idleHours: 168,
|
||||||
|
absoluteHours: 720,
|
||||||
|
})
|
||||||
|
assert.equal(window.idleExpiresAt, '2026-11-01T00:00:00.000Z')
|
||||||
|
assert.equal(window.absoluteExpiresAt, '2026-11-24T00:00:00.000Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('绝对上限小于空闲窗口时以绝对上限为准', () => {
|
||||||
|
const window = resolveSessionExpiryWindow({
|
||||||
|
now: '2026-10-25T00:00:00.000Z',
|
||||||
|
idleHours: 24,
|
||||||
|
absoluteHours: 12,
|
||||||
|
})
|
||||||
|
assert.equal(window.idleExpiresAt, '2026-10-25T12:00:00.000Z')
|
||||||
|
assert.equal(window.absoluteExpiresAt, '2026-10-25T12:00:00.000Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('滑动续期目标不得超过绝对上限', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveSlidingSessionExpiresAt({
|
||||||
|
now: '2026-10-25T00:00:00.000Z',
|
||||||
|
idleHours: 24,
|
||||||
|
absoluteExpiresAt: '2026-11-01T00:00:00.000Z',
|
||||||
|
}),
|
||||||
|
'2026-10-26T00:00:00.000Z',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
resolveSlidingSessionExpiresAt({
|
||||||
|
now: '2026-10-31T18:00:00.000Z',
|
||||||
|
idleHours: 24,
|
||||||
|
absoluteExpiresAt: '2026-11-01T00:00:00.000Z',
|
||||||
|
}),
|
||||||
|
'2026-11-01T00:00:00.000Z',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('绝对上限缺失或非法时仍返回 now + 空闲窗口', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveSlidingSessionExpiresAt({
|
||||||
|
now: '2026-10-25T00:00:00.000Z',
|
||||||
|
idleHours: 24,
|
||||||
|
absoluteExpiresAt: 'invalid',
|
||||||
|
}),
|
||||||
|
'2026-10-26T00:00:00.000Z',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('会话续期响应头同时回传空闲到期与绝对到期', () => {
|
||||||
|
const headers = new Map<string, string>()
|
||||||
|
setSessionExpiryHeaders(
|
||||||
|
{
|
||||||
|
setHeader(name, value) {
|
||||||
|
headers.set(name, value)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expiresAt: '2026-10-26T00:00:00.000Z',
|
||||||
|
absoluteExpiresAt: '2026-11-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert.equal(headers.get('X-Session-Expires-At'), '2026-10-26T00:00:00.000Z')
|
||||||
|
assert.equal(headers.get('X-Session-Absolute-Expires-At'), '2026-11-01T00:00:00.000Z')
|
||||||
|
})
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { addHours } from './time.js'
|
||||||
|
|
||||||
|
export type SessionExpiryWindow = {
|
||||||
|
/** 空闲窗口:每次访问可滑动续期到的目标时间。 */
|
||||||
|
idleExpiresAt: string
|
||||||
|
/** 绝对上限:从登录起算,任何续期都不能突破。 */
|
||||||
|
absoluteExpiresAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHours(value: unknown, fallback: number): number {
|
||||||
|
const parsed = Number(value)
|
||||||
|
return Number.isFinite(parsed) && parsed >= 1 ? Math.floor(parsed) : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录时计算空闲到期与绝对到期;绝对上限小于空闲窗口时以绝对上限为准。 */
|
||||||
|
export function resolveSessionExpiryWindow(input: {
|
||||||
|
now: string
|
||||||
|
idleHours: number
|
||||||
|
absoluteHours: number
|
||||||
|
}): SessionExpiryWindow {
|
||||||
|
const requestedIdleHours = normalizeHours(input.idleHours, 24)
|
||||||
|
const absoluteHours = normalizeHours(input.absoluteHours, requestedIdleHours)
|
||||||
|
const idleHours = Math.min(requestedIdleHours, absoluteHours)
|
||||||
|
return {
|
||||||
|
idleExpiresAt: addHours(input.now, idleHours),
|
||||||
|
absoluteExpiresAt: addHours(input.now, absoluteHours),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 滑动续期目标:now + 空闲窗口,但不得超过 Token 里的绝对上限。 */
|
||||||
|
export function resolveSlidingSessionExpiresAt(input: {
|
||||||
|
now: string
|
||||||
|
idleHours: number
|
||||||
|
absoluteExpiresAt: string
|
||||||
|
}): string {
|
||||||
|
const idleHours = normalizeHours(input.idleHours, 24)
|
||||||
|
const target = addHours(input.now, idleHours)
|
||||||
|
const absoluteMs = Date.parse(String(input.absoluteExpiresAt || ''))
|
||||||
|
if (!Number.isFinite(absoluteMs)) return target
|
||||||
|
return Date.parse(target) <= absoluteMs ? target : new Date(absoluteMs).toISOString()
|
||||||
|
}
|
||||||
@@ -46,11 +46,14 @@ import {
|
|||||||
} from '@/services/admin'
|
} from '@/services/admin'
|
||||||
import {
|
import {
|
||||||
clearAdminSession,
|
clearAdminSession,
|
||||||
|
expireAdminSession,
|
||||||
getAdminDisplayName,
|
getAdminDisplayName,
|
||||||
getAdminRole,
|
getAdminRole,
|
||||||
|
getAdminSessionExpiryDelay,
|
||||||
getAdminToken,
|
getAdminToken,
|
||||||
getAdminTokenExpiresAt,
|
getAdminTokenExpiresAt,
|
||||||
getAdminUsername,
|
getAdminUsername,
|
||||||
|
isAdminSessionStorageKey,
|
||||||
setAdminDisplayName,
|
setAdminDisplayName,
|
||||||
setAdminPermissions,
|
setAdminPermissions,
|
||||||
} from '@/utils/admin-auth'
|
} from '@/utils/admin-auth'
|
||||||
@@ -77,7 +80,7 @@ export default function AdminLayout() {
|
|||||||
)
|
)
|
||||||
const role = getAdminRole()
|
const role = getAdminRole()
|
||||||
const username = getAdminUsername() || 'admin'
|
const username = getAdminUsername() || 'admin'
|
||||||
const expiresAt = getAdminTokenExpiresAt()
|
const [expiresAt, setExpiresAt] = useState(() => getAdminTokenExpiresAt())
|
||||||
const [soundEnabled, setSoundEnabled] = useState(
|
const [soundEnabled, setSoundEnabled] = useState(
|
||||||
() => window.localStorage.getItem(NOTIFICATION_SOUND_KEY) === '1',
|
() => window.localStorage.getItem(NOTIFICATION_SOUND_KEY) === '1',
|
||||||
)
|
)
|
||||||
@@ -96,6 +99,43 @@ export default function AdminLayout() {
|
|||||||
}
|
}
|
||||||
}, [adminSessionQuery.data])
|
}, [adminSessionQuery.data])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const navigateToLogin = () => {
|
||||||
|
if (!window.location.hash.startsWith('#/admin/login')) {
|
||||||
|
void navigate('/admin/login', { replace: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleSessionExpired = () => navigateToLogin()
|
||||||
|
const handleSessionRenewed = () => setExpiresAt(getAdminTokenExpiresAt())
|
||||||
|
const handleStorage = (event: StorageEvent) => {
|
||||||
|
if (!isAdminSessionStorageKey(event.key)) return
|
||||||
|
if (event.newValue === null) {
|
||||||
|
expireAdminSession()
|
||||||
|
navigateToLogin()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleSessionRenewed()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('admin-session-expired', handleSessionExpired)
|
||||||
|
window.addEventListener('admin-session-renewed', handleSessionRenewed)
|
||||||
|
window.addEventListener('storage', handleStorage)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('admin-session-expired', handleSessionExpired)
|
||||||
|
window.removeEventListener('admin-session-renewed', handleSessionRenewed)
|
||||||
|
window.removeEventListener('storage', handleStorage)
|
||||||
|
}
|
||||||
|
}, [navigate])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const delay = getAdminSessionExpiryDelay(expiresAt)
|
||||||
|
if (delay === null) return
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
expireAdminSession()
|
||||||
|
}, delay)
|
||||||
|
return () => window.clearTimeout(timer)
|
||||||
|
}, [expiresAt])
|
||||||
|
|
||||||
const displayName =
|
const displayName =
|
||||||
adminSessionQuery.data?.data.user?.displayName || getAdminDisplayName() || username
|
adminSessionQuery.data?.data.user?.displayName || getAdminDisplayName() || username
|
||||||
|
|
||||||
@@ -121,7 +161,7 @@ export default function AdminLayout() {
|
|||||||
onEvent: (event) => applyAdminRealtimeEvent(event, queryClient),
|
onEvent: (event) => applyAdminRealtimeEvent(event, queryClient),
|
||||||
// 会话过期时清登录态回登录页,避免 SSE 带着失效 token 无限重连刷 401。
|
// 会话过期时清登录态回登录页,避免 SSE 带着失效 token 无限重连刷 401。
|
||||||
onAuthExpired: () => {
|
onAuthExpired: () => {
|
||||||
clearAdminSession()
|
expireAdminSession()
|
||||||
void navigate('/admin/login', { replace: true, state: { from: location } })
|
void navigate('/admin/login', { replace: true, state: { from: location } })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
import { App, Button, Layout, Menu, Typography } from 'antd'
|
import { App, Button, Layout, Menu, Typography } from 'antd'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import type { MenuProps } from 'antd'
|
import type { MenuProps } from 'antd'
|
||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Outlet, useLocation, useNavigate } from 'react-router'
|
import { Outlet, useLocation, useNavigate } from 'react-router'
|
||||||
|
|
||||||
import { fetchWorkerAfterSalesCases, fetchWorkerProfile, logoutWorker } from '@/services/worker'
|
import { fetchWorkerAfterSalesCases, fetchWorkerProfile, logoutWorker } from '@/services/worker'
|
||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
getWorkerToken,
|
getWorkerToken,
|
||||||
getWorkerTokenExpiresAt,
|
getWorkerTokenExpiresAt,
|
||||||
getWorkerUsername,
|
getWorkerUsername,
|
||||||
|
isWorkerSessionExpiryKey,
|
||||||
isWorkerSessionStorageKey,
|
isWorkerSessionStorageKey,
|
||||||
} from '@/utils/worker-auth'
|
} from '@/utils/worker-auth'
|
||||||
import { useRealtimeEvents, type RealtimeEvent } from '@/hooks/useRealtimeEvents'
|
import { useRealtimeEvents, type RealtimeEvent } from '@/hooks/useRealtimeEvents'
|
||||||
@@ -100,6 +101,8 @@ export default function WorkerLayout() {
|
|||||||
[afterSalesPendingCount],
|
[afterSalesPendingCount],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const [sessionExpiresAt, setSessionExpiresAt] = useState(() => getWorkerTokenExpiresAt())
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const navigateToLogin = () => {
|
const navigateToLogin = () => {
|
||||||
if (window.location.pathname !== '/') {
|
if (window.location.pathname !== '/') {
|
||||||
@@ -107,30 +110,38 @@ export default function WorkerLayout() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const handleSessionExpired = () => navigateToLogin()
|
const handleSessionExpired = () => navigateToLogin()
|
||||||
|
const handleSessionRenewed = () => setSessionExpiresAt(getWorkerTokenExpiresAt())
|
||||||
const handleStorage = (event: StorageEvent) => {
|
const handleStorage = (event: StorageEvent) => {
|
||||||
if (isWorkerSessionStorageKey(event.key) && event.newValue === null) {
|
if (!isWorkerSessionStorageKey(event.key)) return
|
||||||
|
if (event.newValue === null) {
|
||||||
expireWorkerSession()
|
expireWorkerSession()
|
||||||
navigateToLogin()
|
navigateToLogin()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isWorkerSessionExpiryKey(event.key)) {
|
||||||
|
handleSessionRenewed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('worker-session-expired', handleSessionExpired)
|
window.addEventListener('worker-session-expired', handleSessionExpired)
|
||||||
|
window.addEventListener('worker-session-renewed', handleSessionRenewed)
|
||||||
window.addEventListener('storage', handleStorage)
|
window.addEventListener('storage', handleStorage)
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('worker-session-expired', handleSessionExpired)
|
window.removeEventListener('worker-session-expired', handleSessionExpired)
|
||||||
|
window.removeEventListener('worker-session-renewed', handleSessionRenewed)
|
||||||
window.removeEventListener('storage', handleStorage)
|
window.removeEventListener('storage', handleStorage)
|
||||||
}
|
}
|
||||||
}, [navigate])
|
}, [navigate])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const delay = getWorkerSessionExpiryDelay(getWorkerTokenExpiresAt())
|
const delay = getWorkerSessionExpiryDelay(sessionExpiresAt)
|
||||||
if (delay === null) return
|
if (delay === null) return
|
||||||
|
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
expireWorkerSession()
|
expireWorkerSession()
|
||||||
}, delay)
|
}, delay)
|
||||||
return () => window.clearTimeout(timer)
|
return () => window.clearTimeout(timer)
|
||||||
}, [])
|
}, [sessionExpiresAt])
|
||||||
|
|
||||||
useRealtimeEvents({
|
useRealtimeEvents({
|
||||||
url: '/api/v1/worker/realtime',
|
url: '/api/v1/worker/realtime',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { clearAdminSession, getAdminToken } from '@/utils/admin-auth'
|
import { expireAdminSession, getAdminToken, setAdminSessionExpiresAt } from '@/utils/admin-auth'
|
||||||
import { isWorkerSite } from '@/utils/site'
|
import { isWorkerSite } from '@/utils/site'
|
||||||
import { expireWorkerSession, getWorkerToken } from '@/utils/worker-auth'
|
import { expireWorkerSession, getWorkerToken, setWorkerSessionExpiresAt } from '@/utils/worker-auth'
|
||||||
|
|
||||||
export interface ApiEnvelope<T> {
|
export interface ApiEnvelope<T> {
|
||||||
code: number
|
code: number
|
||||||
@@ -20,6 +20,7 @@ const http = axios.create({
|
|||||||
|
|
||||||
http.interceptors.response.use(
|
http.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
|
applySessionExpiryHeaders(response)
|
||||||
const data = response.data as ApiEnvelope<unknown>
|
const data = response.data as ApiEnvelope<unknown>
|
||||||
|
|
||||||
// Business-level error: HTTP 200 but code !== 0
|
// Business-level error: HTTP 200 but code !== 0
|
||||||
@@ -36,6 +37,7 @@ http.interceptors.response.use(
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
|
applySessionExpiryHeaders(error?.response)
|
||||||
const responseMessage =
|
const responseMessage =
|
||||||
typeof error?.response?.data?.msg === 'string' ? error.response.data.msg.trim() : ''
|
typeof error?.response?.data?.msg === 'string' ? error.response.data.msg.trim() : ''
|
||||||
const fallbackMessage = resolveFallbackHttpMessage(error)
|
const fallbackMessage = resolveFallbackHttpMessage(error)
|
||||||
@@ -57,7 +59,7 @@ http.interceptors.response.use(
|
|||||||
const errorCode = String(error?.response?.data?.errorCode || '').trim()
|
const errorCode = String(error?.response?.data?.errorCode || '').trim()
|
||||||
|
|
||||||
if (status === 401 && shouldClearAdminSession(errorCode)) {
|
if (status === 401 && shouldClearAdminSession(errorCode)) {
|
||||||
clearAdminSession()
|
expireAdminSession()
|
||||||
|
|
||||||
if (
|
if (
|
||||||
window.location.hash.startsWith('#/admin') &&
|
window.location.hash.startsWith('#/admin') &&
|
||||||
@@ -105,6 +107,37 @@ http.interceptors.request.use((config) => {
|
|||||||
return config
|
return config
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务端每次校验会话都会回传滑动续期后的空闲到期时间;
|
||||||
|
* 这里同步到 localStorage,WorkerLayout / AdminLayout 的定时器会自动重排。
|
||||||
|
*/
|
||||||
|
function applySessionExpiryHeaders(
|
||||||
|
response:
|
||||||
|
| {
|
||||||
|
config?: { url?: string }
|
||||||
|
headers?: Record<string, unknown> & { get?: (name: string) => unknown }
|
||||||
|
}
|
||||||
|
| undefined,
|
||||||
|
) {
|
||||||
|
if (!response) return
|
||||||
|
const url = String(response.config?.url || '')
|
||||||
|
if (!url.startsWith('/api/v1/admin') && !url.startsWith('/api/v1/worker')) return
|
||||||
|
|
||||||
|
const headers = response.headers
|
||||||
|
const raw =
|
||||||
|
typeof headers?.get === 'function'
|
||||||
|
? headers.get('x-session-expires-at')
|
||||||
|
: headers?.['x-session-expires-at']
|
||||||
|
const expiresAt = String(raw || '').trim()
|
||||||
|
if (!expiresAt) return
|
||||||
|
|
||||||
|
if (url.startsWith('/api/v1/admin')) {
|
||||||
|
setAdminSessionExpiresAt(expiresAt)
|
||||||
|
} else {
|
||||||
|
setWorkerSessionExpiresAt(expiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resolveFallbackHttpMessage(error: unknown) {
|
function resolveFallbackHttpMessage(error: unknown) {
|
||||||
const message = String((error as { message?: string })?.message ?? '')
|
const message = String((error as { message?: string })?.message ?? '')
|
||||||
|
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ export function fetchWorkerSession() {
|
|||||||
return apiGet<{
|
return apiGet<{
|
||||||
authenticated: boolean
|
authenticated: boolean
|
||||||
expiresAt: string
|
expiresAt: string
|
||||||
|
absoluteExpiresAt?: string
|
||||||
worker: WorkerUser
|
worker: WorkerUser
|
||||||
}>('/api/v1/worker/auth/session')
|
}>('/api/v1/worker/auth/session')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export type AdminUserStatus = 'active' | 'disabled'
|
|||||||
export interface AdminLoginResponse {
|
export interface AdminLoginResponse {
|
||||||
token: string
|
token: string
|
||||||
expiresAt: string
|
expiresAt: string
|
||||||
|
absoluteExpiresAt?: string
|
||||||
user: {
|
user: {
|
||||||
userId: number
|
userId: number
|
||||||
username: string
|
username: string
|
||||||
@@ -41,6 +42,7 @@ export interface AdminDeviceStatusResponse {
|
|||||||
export interface AdminSessionSummary {
|
export interface AdminSessionSummary {
|
||||||
authenticated: boolean
|
authenticated: boolean
|
||||||
expiresAt: string
|
expiresAt: string
|
||||||
|
absoluteExpiresAt?: string
|
||||||
user: {
|
user: {
|
||||||
userId: number
|
userId: number
|
||||||
username: string
|
username: string
|
||||||
|
|||||||
@@ -721,6 +721,7 @@ export type Pagination = {
|
|||||||
export type WorkerLoginResponse = {
|
export type WorkerLoginResponse = {
|
||||||
token: string
|
token: string
|
||||||
expiresAt: string
|
expiresAt: string
|
||||||
|
absoluteExpiresAt?: string
|
||||||
worker: WorkerUser
|
worker: WorkerUser
|
||||||
passwordWeak?: boolean
|
passwordWeak?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,16 @@ const ADMIN_DISPLAY_NAME_KEY = 'order-site-admin-display-name'
|
|||||||
const ADMIN_ROLE_KEY = 'order-site-admin-role'
|
const ADMIN_ROLE_KEY = 'order-site-admin-role'
|
||||||
const ADMIN_PERMISSIONS_KEY = 'order-site-admin-permissions'
|
const ADMIN_PERMISSIONS_KEY = 'order-site-admin-permissions'
|
||||||
|
|
||||||
|
const ADMIN_SESSION_STORAGE_KEYS = new Set([
|
||||||
|
ADMIN_TOKEN_KEY,
|
||||||
|
ADMIN_EXPIRES_AT_KEY,
|
||||||
|
ADMIN_USER_ID_KEY,
|
||||||
|
ADMIN_USERNAME_KEY,
|
||||||
|
ADMIN_DISPLAY_NAME_KEY,
|
||||||
|
ADMIN_ROLE_KEY,
|
||||||
|
ADMIN_PERMISSIONS_KEY,
|
||||||
|
])
|
||||||
|
|
||||||
const ADMIN_ROLE_LEVEL: Record<'support' | 'operator' | 'admin' | 'finance', number> = {
|
const ADMIN_ROLE_LEVEL: Record<'support' | 'operator' | 'admin' | 'finance', number> = {
|
||||||
support: 1,
|
support: 1,
|
||||||
operator: 2,
|
operator: 2,
|
||||||
@@ -143,6 +153,37 @@ export function clearAdminSession() {
|
|||||||
localStorage.removeItem(ADMIN_PERMISSIONS_KEY)
|
localStorage.removeItem(ADMIN_PERMISSIONS_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 滑动续期:只更新本地空闲到期时间,Token 本身不重签(exp 是绝对上限)。 */
|
||||||
|
export function setAdminSessionExpiresAt(expiresAt: string) {
|
||||||
|
const normalized = String(expiresAt || '').trim()
|
||||||
|
if (!normalized || normalized === getAdminTokenExpiresAt()) return
|
||||||
|
localStorage.setItem(ADMIN_EXPIRES_AT_KEY, normalized)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.dispatchEvent(new Event('admin-session-renewed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清理失效会话并通知当前标签页,避免并发请求各自重复跳转。 */
|
||||||
|
export function expireAdminSession() {
|
||||||
|
const hadSession = Boolean(getAdminToken() || getAdminTokenExpiresAt())
|
||||||
|
clearAdminSession()
|
||||||
|
if (hadSession && typeof window !== 'undefined') {
|
||||||
|
window.dispatchEvent(new Event('admin-session-expired'))
|
||||||
|
}
|
||||||
|
return hadSession
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAdminSessionStorageKey(key: string | null) {
|
||||||
|
return Boolean(key && ADMIN_SESSION_STORAGE_KEYS.has(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAdminSessionExpiryDelay(expiresAt: string, now = Date.now()) {
|
||||||
|
if (!expiresAt) return null
|
||||||
|
const expiresAtMs = Date.parse(expiresAt)
|
||||||
|
if (!Number.isFinite(expiresAtMs)) return null
|
||||||
|
return Math.max(0, expiresAtMs - now)
|
||||||
|
}
|
||||||
|
|
||||||
export function hasAdminSession() {
|
export function hasAdminSession() {
|
||||||
const token = getAdminToken()
|
const token = getAdminToken()
|
||||||
const expiresAt = getAdminTokenExpiresAt()
|
const expiresAt = getAdminTokenExpiresAt()
|
||||||
|
|||||||
@@ -73,6 +73,16 @@ export function clearWorkerSession() {
|
|||||||
localStorage.removeItem(WORKER_STATUS_KEY)
|
localStorage.removeItem(WORKER_STATUS_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 滑动续期:只更新本地空闲到期时间,Token 本身不重签(exp 是绝对上限)。 */
|
||||||
|
export function setWorkerSessionExpiresAt(expiresAt: string) {
|
||||||
|
const normalized = String(expiresAt || '').trim()
|
||||||
|
if (!normalized || normalized === getWorkerTokenExpiresAt()) return
|
||||||
|
localStorage.setItem(WORKER_EXPIRES_AT_KEY, normalized)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.dispatchEvent(new Event('worker-session-renewed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 清理失效会话并通知当前标签页,避免并发请求各自重复跳转。 */
|
/** 清理失效会话并通知当前标签页,避免并发请求各自重复跳转。 */
|
||||||
export function expireWorkerSession() {
|
export function expireWorkerSession() {
|
||||||
const hadSession = Boolean(getWorkerToken() || getWorkerTokenExpiresAt())
|
const hadSession = Boolean(getWorkerToken() || getWorkerTokenExpiresAt())
|
||||||
@@ -87,6 +97,10 @@ export function isWorkerSessionStorageKey(key: string | null) {
|
|||||||
return Boolean(key && WORKER_SESSION_STORAGE_KEYS.has(key))
|
return Boolean(key && WORKER_SESSION_STORAGE_KEYS.has(key))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isWorkerSessionExpiryKey(key: string | null) {
|
||||||
|
return key === WORKER_EXPIRES_AT_KEY
|
||||||
|
}
|
||||||
|
|
||||||
export function getWorkerSessionExpiryDelay(expiresAt: string, now = Date.now()) {
|
export function getWorkerSessionExpiryDelay(expiresAt: string, now = Date.now()) {
|
||||||
if (!expiresAt) return null
|
if (!expiresAt) return null
|
||||||
const expiresAtMs = Date.parse(expiresAt)
|
const expiresAtMs = Date.parse(expiresAt)
|
||||||
|
|||||||
Reference in New Issue
Block a user