从零重构外卖系统(九):Vue 管理端从登录开始,怎样建立可靠的会话、请求与权限基础
系列:Han Menu 外卖系统实践 · 从第一篇开始
系列:Han Menu 外卖系统实践 · PC-1
面向读者:已经能阅读 Java 后端和基础 JavaScript,希望通过真实工程理解 Vue 3、TypeScript、前端分层、身份恢复和异步请求竞争的开发者。
本篇依据 PC-1 提交
be73fb3编写,前置后端契约来自09bf306。代码展示实际关键实现;省略其他模块、构造依赖或页面模板的片段不是独立完整工程。PC-1 已实现员工登录、身份恢复、应用布局、本人改密、退出、统一请求与只读工作台。商品管理、订单履约页面、通知连接和经营报表页面仍属于后续 PC 阶段。
1. 第一部分前端为什么不从十个业务表格开始
后端已经有订单、商品、顾客、支付、退款、通知和统计,管理端看起来只需要把这些接口接进页面。
但如果先复制十份列表代码,再补认证,很容易遇到这些问题:
- 有的请求使用旧 token,有的请求漏带 token。
- 刷新页面后直接相信浏览器保存的 ADMIN 角色。
- 网络暂时失败,界面却继续展示旧身份的管理页面。
- 员工退出后,旧请求迟到,又把个人数据写回缓存。
- 旧账号返回 401,把刚登录的新账号清掉。
- 某个普通业务接口返回 403,却被全局拦截器当作退出登录。
PC-1 先把这些跨页面问题变成统一能力,再让后续业务模块接入。
因此,这一阶段的“可运行基础”包括真实会话生命周期,而不只是一个有菜单和登录框的静态壳。
2. 先固定工具职责,再讨论版本
工程位于仓库中的 admin/,独立构建静态资源;Java 后端仍然单独构建可执行 JAR。
本次提交锁定的主要工具如下:
| 工具 | 提交中的版本 | 在项目中的职责 |
|---|---|---|
| Node | 24.21.0 | 前端工具运行环境 |
| pnpm | 12.4.2 | 包管理、脚本与锁文件 |
| Vue | 3.5.43 | 响应式组件与页面 |
| TypeScript | 5.9.3 | 静态类型检查 |
| Vite | 8.3.0 | 开发服务和生产构建 |
| Vue Router | 5.3.1 | 页面路由和守卫 |
| Pinia | 4.0.3 | 当前会话状态 |
| TanStack Vue Query | 5.103.1 | 服务端查询数据与缓存生命周期 |
| antdv-next | 1.5.4 | 表单、布局和业务界面组件 |
| openapi-typescript / openapi-fetch | 7.13.0 / 0.17.0 | 契约类型生成与类型化请求 |
这些是本篇对应提交的选择,不代表读者阅读时的最新版,也不应仅凭版本数字判断兼容性。
本次 TypeScript 选择 5.9.3,是为了满足类型生成器和 ESLint 工具的共同 peer 约束。工程没有通过忽略 peer 检查来强行安装不兼容组合。
直接依赖、packageManager 和 pnpm-lock.yaml 一起固定。锁文件减少解析漂移,但不能替代类型检查、运行测试和真实组件验证。
3. 前端也按职责分层,但不机械复制后端目录
当前结构:
admin/src/
├── app/
│ ├── router/ 路由、访问规则、导航
│ ├── layouts/ 应用布局、状态页
│ ├── styles/ 页面样式
│ ├── theme.json 组件主题参数
│ └── testing/ 开发环境组件验收页
├── modules/
│ ├── auth/
│ │ ├── api/ 会话HTTP用法与身份解析
│ │ ├── model/ Pinia会话状态与存储
│ │ ├── pages/ 登录页与账号页
│ │ └── index.ts 公开入口
│ └── workspace/
│ ├── api/
│ ├── pages/
│ └── index.ts
└── shared/
├── api/ 统一传输、契约类型、会话端口、错误
├── lib/ 通用时间展示
└── ui/ 通用错误提示
依赖方向是:
flowchart TD
A[app 装配与路由] --> AUTH[modules/auth 公开入口]
A --> WORK[modules/workspace 公开入口]
AUTH --> SH[shared 通用能力]
WORK --> SH
SH --> WEB[浏览器API与通用库]
shared 不导入业务模块;业务模块不导入 app;跨模块通过 index.ts 访问。
后端 DDD 的思想在这里继续体现为职责与依赖约束,但并没有强制每个 Vue 页面都建立 domain/application/infrastructure 四层,也没有为所有业务制造 BaseStore 或 BaseService。
公开入口同时保留异步页面分包
身份模块的实际入口:
/** 身份模块公开会话用例;页面通过加载函数保持独立分包。 */
export { useSessionStore } from './model/session.store'
export type { StaffIdentity } from './api/session'
export const loadLoginPage = () => import('./pages/LoginPage.vue')
export const loadAccountPage = () => import('./pages/AccountPage.vue')
应用层可以使用公开的 store、类型和页面加载函数,不必直接钻进 auth 内部目录。
加载函数保留动态 import,而不是在一个巨大 index 文件里静态导入所有页面。模块边界与打包边界可以相互配合。
项目还通过 check:boundaries 检查这些依赖。当前检查器是针对项目约定的源码导入扫描,不是完整 TypeScript 编译器级的所有依赖证明;不能把它等同于后端 ArchUnit 的全部能力。
4. 启动顺序会影响第一次路由访问
实际入口如下:
import { createApp, watch } from 'vue'
import { createPinia } from 'pinia'
import { VueQueryPlugin } from '@tanstack/vue-query'
import App from './App.vue'
import { router, installRouteGuards } from './app/router'
import { queryClient } from './shared/api/query-client'
import { useSessionStore } from './modules/auth'
import 'antdv-next/dist/reset.css'
import './app/styles/main.css'
/** 启动顺序保证路由守卫可使用 Pinia,且整棵组件树共用同一查询生命周期。 */
const app = createApp(App)
app.use(createPinia())
app.use(VueQueryPlugin, { queryClient })
installRouteGuards()
app.use(router)
const session = useSessionStore()
watch(
() => session.status,
(state) => {
if (state === 'anonymous' && router.currentRoute.value.meta.requiresAuth)
void router.replace({
name: 'login',
query: { redirect: router.currentRoute.value.fullPath },
})
},
)
void router.isReady().then(() => app.mount('#app'))
这段代码的顺序有明确作用:
- 先安装 Pinia,让守卫可以取得会话 store。
- 安装共享 QueryClient,让页面共用查询生命周期。
- 安装守卫,再启用 Router。
- 监听会话失效,离开需要身份的当前页面。
- 等路由初次准备完成后挂载应用。
如果模块刚加载就调用依赖 Pinia 的 store,而 Pinia 还没有安装,就可能在刷新深链接时出现初始化问题。
router.isReady() 也让首次路由判断与页面挂载保持明确顺序。它不是服务端授权,服务端仍然要检查每一次受保护请求。
5. OpenAPI 生成的是类型,不是一个假后端
工程提交了 admin/contracts/openapi.json,通过脚本生成 src/shared/api/schema.d.ts。
实际生成脚本:
import { readFile, writeFile } from 'node:fs/promises'
import openapiTS, { astToString } from 'openapi-typescript'
/** 从已提交的服务端契约生成类型;检查模式拒绝契约和类型漂移。 */
const schema = JSON.parse(
await readFile(new URL('../contracts/openapi.json', import.meta.url), 'utf8'),
)
const output =
'// 自动生成:依据 contracts/openapi.json,禁止手工修改。\n' +
astToString(await openapiTS(schema))
const path = new URL('../src/shared/api/schema.d.ts', import.meta.url)
if (process.argv.includes('--check')) {
if ((await readFile(path, 'utf8')) !== output)
throw new Error('接口类型已过期,请运行 pnpm api:generate')
} else await writeFile(path, output)
api:generate 重新生成文件;api:check 则在检查模式下比较结果,发现契约快照与类型不一致就失败。
这份检查可以离线执行,但它只证明“已提交快照与生成类型一致”,不能自动证明运行中的服务器也还是同一份契约。
后端接口变化时,需要重新取得 /v3/api-docs、核对差异,并在同一变更中更新快照与生成文件。
类型化调用怎样减少手写协议错误
统一客户端的装配片段:
export const api = createClient<paths>({
baseUrl: window.location.origin,
fetch: authenticatedFetch(sessionBridge),
});
业务 API 直接调用真实路径,例如:
export async function createSession(username: string, password: string) {
const result = await api.POST('/api/v1/sessions', { body: { username, password } })
if (!result.response.ok) throw problemFromResponse(result.error, result.response)
const session = parseSession(JSON.stringify(result.data))
if (!session || result.data?.tokenType !== 'Bearer')
throw new ApiProblem(502, '服务器返回的登录状态不完整', 'INVALID_SESSION')
return session
}
路径、方法、body 和响应结构会受到生成类型约束;具体使用方式可参考 openapi-fetch 官方文档。
但 TypeScript 不会替你在浏览器运行时验证外部 JSON。服务端故障、契约漂移或代理异常,仍然可能给你意料之外的数据。
6. 身份字段为什么要做运行时校验
PC-1 不把“接口返回 200”直接当作可以进入管理员区域的充分条件。
身份解析函数如下:
export function parseIdentity(value: unknown): StaffIdentity {
const data = value && typeof value === 'object' ? (value as Record<string, unknown>) : {}
if (
typeof data.id !== 'string' ||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(data.id) ||
typeof data.username !== 'string' ||
typeof data.displayName !== 'string' ||
(data.role !== 'ADMIN' && data.role !== 'STAFF')
)
throw new ApiProblem(502, '服务器返回的身份信息不完整', 'INVALID_IDENTITY')
return {
id: data.id,
username: data.username,
displayName: data.displayName,
role: data.role,
}
}
它要求 UUID、用户名、显示名称和 ADMIN/STAFF 角色字段完整,再构造前端自己的最小身份视图。
unknown 表示还没有被证明形状的数据。收窄与检查之后,才能把它当作可信的前端身份对象。
这与写一句 result as StaffIdentity 不同:类型断言只影响编译器,不会在运行时检查角色值。
当前对认证关键响应做了显式解析,并没有为所有工作台或未来业务 DTO 实现一套通用运行时校验器。生成类型也不能代替服务端资源授权。
7. 浏览器存储里有 token,为什么仍然不是已登录
7.1 只保存令牌和截止时间
持久化结构很小:
export interface StoredSession {
accessToken: string;
expiresAt: string;
}
不保存可被信任的身份角色。刷新之后,必须重新 GET /api/v1/me。
解析存储的实际代码:
export function parseSession(value: string | null, now = Date.now()): StoredSession | null {
try {
const parsed: unknown = JSON.parse(value || 'null')
if (!parsed || typeof parsed !== 'object') return null
const record = parsed as Record<string, unknown>
if (
typeof record.accessToken !== 'string' ||
!/^hme_[A-Za-z0-9_-]{43}$/.test(record.accessToken) ||
typeof record.expiresAt !== 'string' ||
!(Date.parse(record.expiresAt) > now)
)
return null
return { accessToken: record.accessToken, expiresAt: record.expiresAt }
} catch {
return null
}
}
hme_ 前缀、长度与到期时刻检查,只排除明显不合法的本地候选值。它们不能证明服务端数据库中会话仍然有效。
员工令牌是不透明 Bearer,不是需要前端解码的 JWT。前端不能从它推断角色,也不需要自己“验证 JWT 签名”。
7.2 sessionStorage 的边界
当前使用内存和 sessionStorage,没有把会话扩大为长期 localStorage 记忆登录。
浏览器禁用存储时,代码退回当前内存会话,刷新后重新登录。
sessionStorage 不是加密保险箱,也不是 XSS 防护。如果恶意脚本已经在同一来源执行,仍然可能接触前端可读取的数据。选择存储范围并不能免除其他前端安全措施。
8. 用状态机表达恢复过程,不要只有一个 isLoggedIn
会话 store 使用四种状态:
stateDiagram-v2
[*] --> anonymous
anonymous --> restoring: 登录响应或存储中的候选会话
restoring --> authenticated: me校验通过且未到期
restoring --> anonymous: 候选失效或身份拒绝
restoring --> unavailable: 暂时故障或响应不完整
unavailable --> restoring: 用户重试验证
authenticated --> anonymous: 退出、改密、到期或401
authenticated 计算值还要求 identity 非空,不能只看 token 是否存在。
同时触发多个路由恢复,只做一次身份请求
async function restore(force = false): Promise<void> {
if (restoring) return restoring
if (initialized && !force) return
initialized = true
const stored = readSession()
if (!stored) {
clear()
return
}
restoring = verify(stored).finally(() => {
restoring = null
})
return restoring
}
restoring 保存正在进行的 Promise。多个需要身份的入口同时恢复时,共享这一轮验证。
initialized 避免已经完成初始化后,每次普通路由跳转都无条件重新请求 /me。需要用户重试时,用 restore(true) 明确再次校验。
这不等于永久缓存权限:后续受保护请求仍由服务端检查,前端也会处理 401 和到期时刻。
9. 恢复遇到 503,不应默认放行,也不必丢掉所有重试线索
验证候选会话的核心实现:
async function verify(session: StoredSession) {
sessionBridge.replace(session.accessToken)
const epoch = sessionBridge.snapshot().generation
status.value = 'restoring'
try {
const employee = await currentIdentity()
if (!sessionBridge.isCurrent(epoch)) return
if (!parseFuture(session)) {
clear('登录已到期,请重新登录')
return
}
identity.value = employee
status.value = 'authenticated'
restoreError.value = null
writeSession(session)
schedule(session)
} catch (error) {
if (!sessionBridge.isCurrent(epoch)) return
if (error instanceof ApiProblem && [401, 403].includes(error.status)) {
clear(error.message)
return
}
identity.value = null
status.value = 'unavailable'
restoreError.value = error
// 暂时故障仅保留用于再次验证的凭证,不能允许进入受保护页面。
writeSession(session)
}
}
这段代码区分了两种情况:
| 情况 | 前端行为 |
|---|---|
| 身份验证成功且会话未到期 | 保存当前身份、开放页面、设置到期清理 |
| 身份恢复返回 401 或 403 | 撤销候选会话 |
| 网络、503 等暂时失败 | identity 为空,状态 unavailable,保留候选用于重试 |
unavailable 不代表“仍然认证成功,只是加载慢一点”。它不能进入受保护页面。
登录页会显示重试入口,而不是根据本地曾经出现过的 ADMIN 角色恢复管理界面。
这也是为什么不能把认证恢复写成下面这种错误伪代码:
如果本地有 token:
不管 me 是否成功,都显示后台
10. HTTP 层需要凭证,却不应该反向依赖 auth store
如果 shared/api 直接导入业务模块的 Pinia store,就形成了 shared 反向依赖 modules/auth。
PC-1 使用一个小型会话端口 SessionBridge:
/** HTTP 层的会话端口:公共代码不反向依赖 Pinia 或身份业务模块。 */
export class SessionBridge {
private token: string | null = null
private generation = 0
private controller = new AbortController()
private expiredListener: () => void = () => undefined
/** 切换凭证同时取消旧请求;递增代数让不支持取消的迟到响应也失效。 */
replace(token: string | null): void {
this.controller.abort()
this.controller = new AbortController()
this.token = token
this.generation++
}
snapshot() {
return { token: this.token, generation: this.generation, signal: this.controller.signal }
}
isCurrent(generation: number) {
return generation === this.generation
}
onExpired(listener: () => void) {
this.expiredListener = listener
}
expire(generation: number) {
if (this.isCurrent(generation)) this.expiredListener()
}
}
export const sessionBridge = new SessionBridge()
HTTP 层只需要知道:
- 当前 token;
- 当前会话代数 generation;
- 本代请求的取消信号;
- 观察到当前会话失效时怎样通知上层。
它不需要知道登录表单、菜单、角色页面或 Pinia 的内部结构。
auth 模块负责驱动 bridge;bridge 通过回调报告失效。这是职责隔离,不是为每个类都机械抽接口。
11. 取消旧请求为什么还不够,还要检查会话代数
11.1 一个容易复现的竞态
sequenceDiagram
participant A as 账号A的旧请求
participant S as 当前会话
participant B as 账号B
S->>A: 发起请求,记录generation=4
S->>S: 退出A,取消旧请求并推进代数
B->>S: 登录B,当前generation=6
A-->>S: 旧请求迟到返回401
S->>S: 发现响应属于4,不处理为当前会话失效
如果任何 401 都调用全局 logout,旧账号的响应就可能清掉新账号。
同样,旧账号的 200 如果写回查询缓存,会让新账号看见旧数据。
11.2 generation 是本地异步归属标识
响应只有满足下面条件,才继续交付:
它与后端订单 version 不同,也不是认证凭证或全系统事件版本。
replace() 先触发旧 AbortController,再创建新信号并递增 generation。支持取消的底层请求尽早停止;即使测试替身或某段异步处理忽略取消,返回时仍检查所属代数。
浏览器 abort 可以取消请求及相关响应读取,但不能被理解为回滚服务器已经执行的业务。接口写入仍然需要服务端版本与幂等规则。AbortController.abort()
12. 统一 HTTP 封装具体承担什么
下面直接展示这一层的实际核心代码:
export function authenticatedFetch(bridge: SessionBridge, transport: typeof fetch = fetch) {
return async (request: Request): Promise<Response> => {
const url = new URL(request.url)
if (url.origin !== window.location.origin || !url.pathname.startsWith('/api/v1/')) {
throw new ApiProblem(0, '拒绝向未授权的地址发送请求', 'INVALID_API_ORIGIN')
}
const session = bridge.snapshot()
const publicRequest =
url.pathname === '/api/v1/storefront' ||
(url.pathname === '/api/v1/sessions' && request.method === 'POST')
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort('timeout'), 15000)
const headers = new Headers(request.headers)
headers.delete('Authorization')
if (!publicRequest && session.token) headers.set('Authorization', `Bearer ${session.token}`)
try {
const response = await transport(
new Request(request, {
headers,
credentials: 'omit',
cache: 'no-store',
signal: AbortSignal.any([request.signal, session.signal, controller.signal]),
}),
)
if (!bridge.isCurrent(session.generation)) throw new DOMException('会话已切换', 'AbortError')
if (response.status === 401 && !publicRequest && session.token)
bridge.expire(session.generation)
return response
} catch (error) {
if (controller.signal.aborted) throw new ApiProblem(0, '请求超时,请重试', 'TIMEOUT')
if (
request.signal.aborted ||
session.signal.aborted ||
(error instanceof DOMException && error.name === 'AbortError')
)
throw new DOMException('请求已取消', 'AbortError')
if (error instanceof ApiProblem) throw error
throw new ApiProblem(0, '无法连接服务器,请检查网络后重试', 'NETWORK_UNAVAILABLE')
} finally {
clearTimeout(timeout)
}
}
}
这段代码值得按职责拆开看。
12.1 限制请求初始目标
只接受当前 origin 且路径以 /api/v1/ 开始的请求。业务代码不能通过这个认证客户端任意向外部 URL 发送员工凭证。
先删除调用方可能带入的 Authorization,再按照会话端口决定是否附加当前 token,避免不同页面各自维护认证头。
12.2 公开请求不附加旧 Bearer
当前公开例外是 storefront,以及 POST sessions。
登录失败产生的 401 不应被解释为“撤销另一个当前会话”;公开门店查询也不需要冒充员工请求。
12.3 合并三种取消原因
signal: AbortSignal.any([
request.signal,
session.signal,
controller.signal,
]),
它们分别来自业务调用方、会话切换和十五秒超时。
取消后不应给用户弹一个普通的“服务器故障”提示;超时则转换为明确的 TIMEOUT。最终无论成功失败都清理本次 timeout 计时器。
12.4 不使用浏览器自动附带的认证 Cookie
credentials: 'omit' 与 Bearer 协议保持一致,cache: 'no-store' 也让请求的缓存行为明确。
这些配置不是对所有安全问题的自动解决。它们只是把本项目当前的认证与数据访问约定落实到统一入口。
13. HTTP 401、业务 403 与恢复失败要分开解释
统一传输层对当前受保护请求的 401 通知会话失效;403 则保留当前身份,由页面显示权限错误。
但是,verify() 在恢复候选身份时又会把 /me 的 403 作为不能认可该候选会话的结果。
这两者并不冲突:
| 位置 | 403 的处理理由 |
|---|---|
| 普通业务查询 | 当前身份可能有效,只是没有某项资源权限 |
/me 身份恢复 |
当前候选无法得到可接受身份,不能据此进入应用 |
模块 API 负责根据 response.ok 把失败响应转换成 ApiProblem。传输层处理网络与会话边界,不替每个页面决定所有业务错误展示。
RFC 9457 的处理只提取允许展示的 detail、code、traceId,并保留必要的 Retry-After。未知正文不会被直接 stringify 成一个弹窗。
登录页会使用 Retry-After 提供等待提示。这是服务端限流的用户体验补充,不能用前端倒计时替代后端 Redis 限流。
14. Pinia 管会话,Vue Query 管服务端查询数据
Pinia store 维护当前认证状态和身份。工作台数据来自服务器,交给 Vue Query 管理加载、失败、缓存和取消生命周期。
统一 QueryClient:
import { QueryClient } from '@tanstack/vue-query'
/** 查询不自动掩盖权限或故障;退出时统一清空,业务写操作从不自动重试。 */
export const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: 30_000, refetchOnWindowFocus: true },
mutations: { retry: false },
},
})
读请求和写请求默认不自动重试,避免把权限错误、业务冲突或一次写请求悄悄重复执行。
这不等于永远不会再次查询。页面可以主动刷新,窗口重新获得焦点时也可能按照查询状态重新读取。
staleTime 为 30 秒,表示这套客户端的查询新鲜度策略,不是服务端保证数据三十秒不会变化。
会话清理先断开旧请求,再清个人数据
function clear(message = '') {
clearTimeout(timer)
sessionBridge.replace(null)
void queryClient.cancelQueries()
queryClient.clear()
identity.value = null
status.value = 'anonymous'
restoreError.value = null
writeSession(null)
reason.value = message
initialized = true
}
当前单账号上下文切换时,先使旧请求代数失效,再取消查询、清缓存、清身份和存储。
只删除 sessionStorage 里的 token,不会自动清掉已经在内存里的查询结果。把这一步集中处理,后续模块就不需要各自猜哪些缓存属于上一位员工。
当前工作台 queryKey 没有同时容纳多个并行账号的设计。若以后支持多身份并行工作区,应重新设计查询作用域,而不只增加更多 store 字段。
15. 菜单隐藏、前端守卫和后端授权是三层职责
实际路由守卫如下:
import type { NavigationGuard } from 'vue-router'
/** 守卫只依赖最小会话能力,方便用真实内存路由验证恢复、角色隔离和安全跳转。 */
interface SessionAccess {
restore: () => Promise<void>
authenticated: boolean
identity: { role: string } | null
}
export function accessGuard(session: SessionAccess): NavigationGuard {
return async (to) => {
await session.restore()
if (!session.authenticated && to.meta.requiresAuth)
return { name: 'login', query: { redirect: to.fullPath } }
if (session.authenticated && to.name === 'login') return { name: 'workspace' }
if (to.meta.admin && session.identity?.role !== 'ADMIN') return { name: 'forbidden' }
return true
}
}
每次导航先完成必要的身份恢复:
- 未认证访问受保护路由,转到登录页并保留站内目标路径。
- 已认证访问登录页,进入工作台。
- 管理员专用路由拒绝普通员工。
父子路由使用 meta 表达认证与管理权限,Vue Router 会按匹配路由合并供访问判断使用的 meta。路由元信息
侧栏再按角色过滤导航,减少没有权限的操作入口。但知道 URL 的用户仍能尝试访问,所以守卫和服务端必须继续检查。
前端显示一个 403 状态页,是 SPA 的页面体验;真正 API 返回的 HTTP 403 则来自服务器。不要因为组件写了 status="403" 就以为已经建立了后端安全边界。
登录后跳转只接受受限站内路径
async function proceed() {
const target = typeof route.query.redirect === 'string' ? route.query.redirect : '/workspace'
await router.replace(
target.startsWith('/') &&
!target.startsWith('//') &&
!target.includes('\\') &&
!target.startsWith('/login')
? target
: '/workspace',
)
}
拒绝 //、反斜杠和登录循环等目标,不把任意 redirect 参数直接当作外部导航地址。
跳回站内路径之后,路由守卫仍然要检查角色,安全跳转检查不能代替权限检查。
16. 到期、改密和退出,都属于会话生命周期
16.1 到期清理不能只等下一个接口返回401
会话成功验证后,按 expiresAt 安排本地清理计时器,并限制为计时器支持的最大等待范围。
这样可以在正常运行中主动清理已经过期的前端身份。浏览器后台休眠和客户端时钟会影响定时器精度,因此服务端会话检查仍然是最终依据。
这里没有自动刷新 token,也没有通过延长本地 expiresAt 来延长服务端会话。
16.2 改密成功后重新登录
实际 API 请求只有两个业务字段:
export async function changePassword(currentPassword: string, newPassword: string) {
const result = await api.PUT('/api/v1/me/password', { body: { currentPassword, newPassword } })
if (!result.response.ok) throw problemFromResponse(result.error, result.response)
}
这里没有后端不存在的 version,也不把完整身份 DTO 回传成请求体。
确认密码只是前端表单校验,不发送给服务器。成功之后 store 清理当前认证状态,后端则按既有安全版本规则撤销旧会话。
16.3 退出请求失败,仍然必须完成本地清理
async function logout() {
try {
await revokeSession()
} finally {
clear()
}
}
finally 保证本地会话被清理。但网络失败时,界面会明确提示“服务端撤销尚未确认”,而不是声称远端会话一定已经不存在。
本地退出和服务端撤销是两个结果;它们可以在网络故障时暂时不同。
PC-1 尚未建立业务 WebSocket,不能把本阶段的清理流程描述为已经实现了 PC 通知连接关闭。后续接入长连接时,需要把连接生命周期一起纳入账号切换。
17. 密码表单也有自己的资源生命周期
账号页面用 reactive 保存表单输入,用 ref 保存 pending、error 和 dirty 状态。
对于刚接触 Vue 的读者,可以这样理解:
- reactive 让对象字段变化被页面观察。
- ref 为一个状态值建立响应式容器,脚本里通过
.value访问。 - computed 表达依赖其他状态的派生值。
- 页面模板对顶层 ref 有解包行为,嵌套对象仍应按实际表达式理解,不要机械删除所有
.value。
密码只存在于当前表单内存中,不写入持久化会话。
路由离开保护的实际片段:
onBeforeRouteLeave(() => {
if (!dirty.value || !session.authenticated) return true
return new Promise<boolean>((resolve) =>
modal.confirm({
title: '放弃尚未提交的修改?',
content: '离开后输入的密码将被清空。',
okText: '放弃修改',
cancelText: '继续编辑',
onOk: () => resolve(true),
onCancel: () => resolve(false),
}),
)
})
如果会话已经失效,允许离开,不能让一个未保存密码表单把用户困在已经无权访问的页面里。
组件卸载时还会移除 beforeunload 监听并清空密码字段。这些行为属于组件生命周期,不应散落在应用里多个互不一致的事件处理器中。
“前端清空密码字符串”也不是对 JavaScript 内存做不可恢复擦除的保证;这里保护的是不继续保存和展示输入,不作超出实现的安全承诺。
18. 主题、中文语言和布局应该集中装配
根组件实际很短:
<script setup lang="ts">
import { ConfigProvider, App as AntApp } from 'antdv-next'
import zhCN from 'antdv-next/locale/zh_CN'
import theme from './app/theme.json'
</script>
<template>
<ConfigProvider :locale="zhCN" :theme="theme"
><AntApp><RouterView /></AntApp
></ConfigProvider>
</template>
ConfigProvider 提供统一主题和中文组件语言;AntApp 为消息、弹窗等上下文能力提供容器。
主题文件中的部分真实参数:
{
"token": {
"colorPrimary": "#265E49",
"colorText": "#202820",
"colorBgLayout": "#F7F7F2",
"colorBgContainer": "#FFFFFF",
"fontSize": 14,
"borderRadius": 6,
"controlHeight": 36
}
}
这是完整主题的摘录,不是只要覆盖这几个值就能得到全部页面布局。
组件主题负责公共视觉语义,页面 CSS 负责布局和留白。当前使用浅鼠尾草侧栏与墨绿强调色,避免每个业务页重新定义一套按钮和状态色。
布局根据窗口宽度切换导航形态:较窄桌面使用折叠侧栏,更窄屏幕使用抽屉导航。它没有为了适配窄屏直接删除账号入口或裁掉操作区。
中文组件 locale 与时间库的中文日期格式也分别配置。不能只把 DatePicker 标签改成中文,就假定整个日期与时区逻辑已经正确。
19. 第一张真实页面:工作台摘要怎样接入
工作台通过模块 API 请求服务器:
export async function getWorkspace(signal?: AbortSignal) {
const result = await api.GET('/api/v1/workspace', { signal })
if (!result.response.ok) throw problemFromResponse(result.error, result.response)
return result.data!
}
页面用 Vue Query 接入:
const query = useQuery({
queryKey: ['workspace'],
queryFn: ({ signal }) => getWorkspace(signal),
});
const data = query.data;
传入取消信号,使查询生命周期可以与组件和会话清理配合。
数据必须表达不同的界面状态
实际模板的关键部分,省略具体摘要区块:
<ProblemAlert :error="query.error.value" retry @retry="query.refetch()" />
<Skeleton v-if="query.isPending.value" active :paragraph="{ rows: 8 }" />
<template v-else-if="data">
<!-- 在这里展示服务端工作台数据。 -->
</template>
等待时显示加载状态,失败时有错误和重试入口;不能在加载失败时灌入一份看起来完整的演示工作台。
缺失值使用 —,不会一律替换成零。对于合法的 0,应使用 ?? 这类空值判断,而不是误用 || 将它当成缺失。
不用浏览器时钟冒充业务更新时间
页面展示服务端 businessDate 和 projection.updatedAt。格式化函数把 UTC 时刻转成上海时间;空值显示“尚未更新”,不会替换为当前浏览器时间。
工作台的待接单、待配送、配送中、取消和退款处理中计数直接来自 P6 摘要,不从某一页订单列表长度推算。
PC-1 没有待接单操作表,没有连接状态展示,也没有伪造“WebSocket 已连接”的提示。
20. 未开放导航与开发验收页,怎样避免变成假的业务功能
未来页面的菜单入口可以存在,但当前被明确禁用。直接访问对应路由时显示“暂未开放”;管理员专用路由依然先检查权限。
不能把一个没有接后端的空表格或假按钮当作已经交付的商品管理、退款后台或通知中心。
开发组件验收页则通过 DEV 条件注册:
...(import.meta.env.DEV
? [{
path: '_dev/components',
component: () => import('../testing/ComponentLab.vue'),
meta: { title: '组件验证', admin: true },
}]
: []),
该片段省略外围 routes 数组。生产构建排除这条路由和对应页面。
验收页用于检查 Table、Form、DatePicker、Upload、Drawer 等真实组件的组合行为。Upload 在这里验证文件选择,不会因此上传业务图片或建立素材库。
设计验收记录也明确区分图稿和真实数据:原设计中的演示营业状态、数量与姓名不作为接口值。PC-1 只实现当前范围,不能声称整套管理台已一比一落地。
21. 单元测试应当故意让旧响应晚到
正常登录成功的测试不能证明会话切换安全。PC-1 通过手动控制 Promise 完成时间,重现旧账号401迟到的情况:
it('上个账号的迟到401不能撤销新账号', async () => {
const bridge = new SessionBridge()
bridge.replace('old')
const expired = vi.fn()
bridge.onExpired(expired)
let resolve!: (value: Response) => void
const transport = vi.fn<typeof fetch>().mockImplementation(
() =>
new Promise<Response>((done) => {
resolve = done
}),
)
const promise = authenticatedFetch(bridge, transport)(new Request(url))
bridge.replace('new')
resolve(new Response(null, { status: 401 }))
await expect(promise).rejects.toMatchObject({ name: 'AbortError' })
expect(expired).not.toHaveBeenCalled()
})
这个 transport 故意不响应取消信号,模拟“底层仍然返回了响应”。最终期望 AbortError,而不是触发新账号的过期回调。
另一个测试用迟到200验证旧数据不会被直接交付;store 测试则让 /me 在清理会话之后才完成,检查它不能重新登录。
这些测试检查的是行为顺序,不是简单确认 abort() 被调用过。
恢复故障与到期使用可控时间
单元测试还覆盖:
- 损坏、过期和错误主体前缀的存储候选。
- 并发恢复只请求一次
/me。 - 503 时不开放路由,重试后重新验证。
- 当前401清除存储与查询缓存。
- 没有新网络请求时,本地到期计时也会清理身份。
- 退出请求失败仍清理本地,并继续向页面报告失败。
- 改密成功后必须重新登录。
不需要为这些顺序问题依赖真实网络随机变慢;用可控 Promise 和计时器可以确定地重现边界。
22. 浏览器测试不能拿开发管理员当试验品
PC-1 的 Playwright 测试使用真实后端 JAR、真实数据库身份和独立临时 schema。
它不复用正在运行的开发服务:
| 用途 | 前端端口 | 后端端口 |
|---|---|---|
| 正常开发 | 5173 | 8080 |
| 浏览器验收 | 15173 | 18081 |
测试配置明确设置 reuseExistingServer: false。测试数据库必须以 _test 结尾,再创建随机 pc1_... schema,退出时先停应用,再清理本次 schema。
它不会使用开发管理员来做改密、停用或造业务数据。固定测试密码只属于临时测试账号,不能拿来当部署凭证。
六项浏览器验收覆盖管理员与员工、刷新恢复、服务端越权拒绝、退出、停用、改密、503恢复和核心组件兼容性。只有503恢复场景明确注入网络故障响应,不能把这些测试统称为全部接口都被 mock。
已有设计与交互验收还保存了桌面及窄屏证据。本篇引用的是提交里的验收结果,没有重新运行浏览器或检查 Vditor 渲染。
23. 本地启动与完整门禁
使用提交锁定的 Node 与 pnpm,在仓库根启动后端:
./scripts/with-env.sh ./mvnw spring-boot:run
另开终端进入管理端:
cd admin
pnpm install --frozen-lockfile
pnpm dev
浏览器访问 http://127.0.0.1:5173。开发代理把 /api 转向本机后端;需要改变目标时使用 admin 的公开配置,不复制后端私钥和数据库密码到前端环境。
这里的 ADMIN_API_TARGET 用于 Vite 开发代理。它不是让浏览器直接携带 Bearer 访问任意第三方地址的开关。
前端与后端分别检查,再统一执行
前端 pnpm verify 包括:
格式 → 契约类型一致性 → ESLint → 模块边界
→ vue-tsc → Vitest → 生产构建
根目录完整入口:
./scripts/verify.sh
它先完成后端验证,再执行前端检查和真实浏览器测试。浏览器测试需要安装项目使用的 Chromium,并准备独立测试库及中间件配置。
根据 PC-1 提交验收记录:155 项后端测试、20 项前端单元测试、6 项浏览器测试通过,无跳过。远程 CI 配置已经同步,但该轮没有实际执行远程 CI,不能将本地通过等同于远程流水线已运行。
发布静态产物,不发布开发服务器
生产构建输出 dist,独立于后端 JAR。部署时要区分:
- 页面 history 路径可以回退 index.html。
/api/应代理后端,不能把 API 错误回退为一份 HTML 页面。- 前端环境变量只放公开配置。
- 当前 Vite 配置里的 WebSocket 代理支持,不等于 PC-1 已建立通知连接。
本篇不提供一个包含真实密码或私钥的部署示例。
24. 第一阶段留下的基础,怎样被后续业务复用
后续商品、顾客、订单、资金或报表页面,都应该继续使用:
- 相同的模块公开入口与依赖方向。
- 相同的类型生成和统一请求能力。
- 当前会话代数、请求取消与缓存清理。
- 服务端提供的真实状态、版本和错误协议。
- 相同的主题、中文语言和状态展示约定。
这些规则让第一阶段的价值不只停留在登录页。它们也不意味着前端可以代替后端判断付款、退款或资源授权。
读者练习
练习一:旧200与旧401。 账号切换后,分别描述旧响应可能造成的数据泄漏和错误退出。为什么取消请求与 generation 检查都需要?
练习二:本地角色被改成ADMIN。 如果浏览器存储被篡改,当前恢复流程还会依据哪些服务端事实决定能否进入页面?类型检查能否阻止用户编辑存储?
练习三:恢复时503。 立即清空凭证、继续开放页面、进入不可用状态等待重试,这三种行为分别会损失什么或引入什么风险?当前实现选择了哪一种?
练习四:接入顾客状态按钮。 应该把整个 ManagedCustomerView 回传,还是只发送 enabled/version?409 时为什么不能把开关强行显示为成功?
练习五:契约生成检查通过。 如果运行中的服务器已经换了字段,为什么 api:check 仍可能通过?如何把后端契约变更与前端快照更新放进同一工作流?
练习六:未来通知连接。 P6 已经有一次性票据和补查协议。接入PC通知模块时,会话清理还应终止哪些资源?不要只把 WebSocket 对象放在组件外的全局变量里。
接下来可以按 PC-2 进入经营基础资料页面,再逐步实现订单、通知、资金与报表。每一阶段都应建立在这套已经验证的认证与请求基础上,而不是重新复制一套拦截器和会话判断。
延伸阅读
系列:Han Menu 外卖系统实践 · 从第一篇开始