feat(admin): art-design-pro integration with local session auth
P1b Task 9: apps/admin based on art-design-pro v3.0.2 (tag, commit d224dd4; source recorded in apps/admin/SOURCE.md), trimmed to layout/login/i18n/request/table-form core. Login wired to real POST /v1/auth/password (single-token session), Bearer header on all requests, 401 interceptor logout+redirect, request-driven sliding renewal (refresh at ~80% TTL, single-flight), best-effort POST /v1/auth/logout on user-menu logout. One protected stub page (相册管理). Dev port 5176 strictPort + /v1 proxy to 8080. Admin keeps its own eslint9 flat config; root eslint ignores apps/admin/**. vitest unit tests for renewal pacing + Playwright e2e (guard redirect, localized wrong-password error, login lands on stub, token persists across reload, logout clears session). Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 主动登出(用户点击退出菜单时使用)
|
||||
*
|
||||
* 区别于 userStore.logOut()(仅清本地态,供 401 拦截器/守卫同步调用):
|
||||
* 这里先尽力调用 POST /v1/auth/logout 让服务端吊销会话,再清本地态。
|
||||
* 网络失败不阻断本地登出——本地令牌丢弃后服务端会话也会自然过期。
|
||||
*/
|
||||
import { fetchLogout } from '@/api/auth'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
|
||||
export async function logoutWithApi(): Promise<void> {
|
||||
const userStore = useUserStore()
|
||||
if (userStore.isLogin && userStore.sessionToken) {
|
||||
try {
|
||||
await fetchLogout()
|
||||
} catch {
|
||||
// 尽力而为:失败静默,继续本地登出
|
||||
}
|
||||
}
|
||||
userStore.logOut()
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// 滑动续期节律的单测:80% TTL 触发点、过期会话不再续期、边界舍入。
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { REFRESH_TTL_RATIO, refreshDueAt, shouldRefreshSession } from './session'
|
||||
|
||||
describe('refreshDueAt', () => {
|
||||
it('在会话起点 + 80% TTL 处触发', () => {
|
||||
const startedAt = Date.parse('2026-09-18T00:00:00Z')
|
||||
const expiresAt = Date.parse('2026-10-18T00:00:00Z') // 30 天 TTL
|
||||
// 30d = 2_592_000_000ms,80% = 2_073_600_000ms
|
||||
expect(refreshDueAt(startedAt, expiresAt)).toBe(startedAt + 2_073_600_000)
|
||||
expect(REFRESH_TTL_RATIO).toBe(0.8)
|
||||
})
|
||||
|
||||
it('TTL 向下取整,避免亚毫秒提前触发', () => {
|
||||
const startedAt = 1_000
|
||||
const expiresAt = 1_999 // TTL=999ms,80%=799.2 → 799
|
||||
expect(refreshDueAt(startedAt, expiresAt)).toBe(1_799)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldRefreshSession', () => {
|
||||
const startedAt = 0
|
||||
const expiresAt = 1_000_000 // TTL=1000s(ms),due at 800_000
|
||||
|
||||
it('未到 80% TTL 不触发', () => {
|
||||
expect(shouldRefreshSession(startedAt, expiresAt, 799_999)).toBe(false)
|
||||
})
|
||||
|
||||
it('到达 80% TTL 触发', () => {
|
||||
expect(shouldRefreshSession(startedAt, expiresAt, 800_000)).toBe(true)
|
||||
expect(shouldRefreshSession(startedAt, expiresAt, 950_000)).toBe(true)
|
||||
})
|
||||
|
||||
it('会话已过期不再续期(交给 401 拦截器登出)', () => {
|
||||
expect(shouldRefreshSession(startedAt, expiresAt, 1_000_000)).toBe(false)
|
||||
expect(shouldRefreshSession(startedAt, expiresAt, 1_200_000)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 会话滑动续期(对齐后端单令牌会话模型)
|
||||
*
|
||||
* 后端语义:登录发放 sessionToken + expiresAt(默认 30 天);
|
||||
* POST /v1/auth/refresh 在有效期内每次把 expiresAt 顺延一个完整 TTL,
|
||||
* 令牌本身不变。因此客户端只需在「已消耗 ~80% TTL」时补一次 refresh。
|
||||
*
|
||||
* 实现约定(计划 Task 9):不设常驻定时器,靠请求驱动——每次 API 成功
|
||||
* 响应后调用 maybeRefreshSession() 检查,到期才发 refresh(单飞,合并并发)。
|
||||
* 纯函数 refreshDueAt/shouldRefreshSession 单测覆盖(session.spec.ts)。
|
||||
*/
|
||||
import { fetchRefreshSession } from '@/api/auth'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
|
||||
/** 续期阈值:消耗 80% TTL 后触发(剩余 20% 缓冲) */
|
||||
export const REFRESH_TTL_RATIO = 0.8
|
||||
|
||||
/**
|
||||
* 计算应触发续期的绝对时间点(ms)。
|
||||
* @param startedAtMs 本段会话起点(登录/上次续期完成的时刻)
|
||||
* @param expiresAtMs 服务端给的绝对过期时间
|
||||
*/
|
||||
export function refreshDueAt(startedAtMs: number, expiresAtMs: number): number {
|
||||
const ttl = expiresAtMs - startedAtMs
|
||||
return startedAtMs + Math.floor(ttl * REFRESH_TTL_RATIO)
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前是否应触发续期:仅在会话仍有效且已过 80% TTL 时为真。
|
||||
* 会话已过期交由 401 拦截器处理,这里不再补 refresh。
|
||||
*/
|
||||
export function shouldRefreshSession(
|
||||
startedAtMs: number,
|
||||
expiresAtMs: number,
|
||||
nowMs: number
|
||||
): boolean {
|
||||
if (expiresAtMs <= nowMs) return false
|
||||
return nowMs >= refreshDueAt(startedAtMs, expiresAtMs)
|
||||
}
|
||||
|
||||
/** 进行中的 refresh(单飞:并发请求只触发一次) */
|
||||
let refreshInFlight: Promise<void> | null = null
|
||||
|
||||
/**
|
||||
* 请求驱动的滑动续期入口:HTTP 层每次成功响应后调用。
|
||||
* 未登录/未到期直接返回;到期则发一次 refresh 并更新过期时间。
|
||||
*/
|
||||
export function maybeRefreshSession(): Promise<void> | null {
|
||||
if (refreshInFlight) return refreshInFlight
|
||||
|
||||
const userStore = useUserStore()
|
||||
const { isLogin, sessionToken, sessionStartedAt, sessionExpiresAt } = userStore
|
||||
if (!isLogin || !sessionToken) return null
|
||||
|
||||
const startedAt = Number(sessionStartedAt)
|
||||
const expiresAtMs = Date.parse(sessionExpiresAt)
|
||||
if (!Number.isFinite(startedAt) || !Number.isFinite(expiresAtMs)) return null
|
||||
if (!shouldRefreshSession(startedAt, expiresAtMs, Date.now())) return null
|
||||
|
||||
refreshInFlight = (async () => {
|
||||
try {
|
||||
const { expiresAt } = await fetchRefreshSession()
|
||||
userStore.renewSession(expiresAt)
|
||||
} catch {
|
||||
// 续期失败:会话真正过期时 401 拦截器会登出;瞬时失败等下次请求再试
|
||||
} finally {
|
||||
refreshInFlight = null
|
||||
}
|
||||
})()
|
||||
return refreshInFlight
|
||||
}
|
||||
Reference in New Issue
Block a user