系列:Han Menu 外卖系统实践 · 从第一篇开始

上一篇:第8篇 · 下一篇:第10篇

系列: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'))

这段代码的顺序有明确作用:

  1. 先安装 Pinia,让守卫可以取得会话 store。
  2. 安装共享 QueryClient,让页面共用查询生命周期。
  3. 安装守卫,再启用 Router。
  4. 监听会话失效,离开需要身份的当前页面。
  5. 等路由初次准备完成后挂载应用。

如果模块刚加载就调用依赖 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 是本地异步归属标识

响应只有满足下面条件,才继续交付:

generation_{request}=generation_{current}

它与后端订单 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 外卖系统实践 · 从第一篇开始

上一篇:第8篇 · 下一篇:第10篇