Skip to content

请求封装与登录态 ​

适用端:移动端 | 类型:机制

移动端没有 axios,请求层是在 uni.request / uni.uploadFile 上挂拦截器 + 一层 Promise 包装自己搭的。这一页讲清楚:一个 http.get('/system/user/page') 从发出到拿到 data 中间经过了哪几道处理、token 是怎么带上又怎么无感刷新的、以及为什么 VITE_APP_API_ENCRYPT_ENABLE=true 却什么也没加密。

登录态在这里有两个不同的判据,很容易混:发请求时用的 validToken(看过期时间) 与 路由拦截用的 hasLogin(双 token 模式下故意不看过期时间)。两者不一致是刻意设计,不是 bug,下面第 3 节展开。

前置阅读:移动端环境准备与启动(VITE_AUTH_MODE / VITE_SERVER_BASEURL / VITE_APP_TENANT_ENABLE 的取值与叠加顺序)、工程结构与约定式路由(src/http/、src/store/、src/router/ 在工程里的位置)。

它解决什么问题 ​

两层职责分明:

层文件管什么
拦截器层src/http/interceptor.ts出站请求:query 序列化、baseUrl 拼接、Authorization、租户头、请求加密。通过 uni.addInterceptor 挂在 request 与 uploadFile 两个 API 上(:104-106),所以文件上传走的是同一套逻辑
包装层src/http/http.ts入站响应 + 失败重试:解密、401 与刷新队列、业务码、错误 toast、网络失败兜底

代码在哪 ​

路径职责
src/http/http.ts:71-212http() 主体;:222-283 是 httpGet/httpPost/httpPut/httpDelete 与 http.get/post/put/delete 别名
src/http/interceptor.ts:19-99httpInterceptor.invoke();:101-108 是 install()
src/http/types.ts:4-16CustomRequestOptions:query / hideErrorToast / original / returnRawResponse / isEncrypt / __isRefreshTokenRetry
src/http/tools/enum.tsResultEnum(Success0 / Success200)
src/http/tools/queryString.tsstringifyQuery(),query 对象 → URL 串
src/http/sse.tsAI 流式请求,走 onChunkReceived 而非 uni.request,超时 10 分钟(:17)
src/store/token.tsPinia token store:登录 / 登出 / 刷新 / validToken / hasLogin,persist: true(:340)
src/store/user.tstenantId / visitTenantId / roles / permissions;fetchUserInfo()(:93)打 /system/auth/get-permission-info
src/router/interceptor.ts路由级登录拦截
src/router/config.ts登录策略、登录页/404/pc-only 等保留页常量
src/utils/index.ts:141-179getEnvBaseUrl() / getEnvBaseUrlRoot() / isDoubleTokenMode
src/utils/encrypt.ts:143+ApiEncrypt 门面 + AES / RSA 两个静态类
src/utils/toLoginPage.ts跳登录页,带 500 ms 防抖(:24、:48)
src/hooks/useAccess.tshasAccessByRoles() / hasAccessByCodes()

核心机制 ​

1. 出站:baseUrl 拼接与 H5 的代理开关 ​

interceptor.ts:32-48:

ts
if (!options.url.startsWith('http')) {
  // #ifdef H5
  if (JSON.parse(import.meta.env.VITE_APP_PROXY_ENABLE)) {
    options.url = import.meta.env.VITE_APP_PROXY_PREFIX + options.url
  }
  else {
    options.url = baseUrl + options.url
  }
  // #endif
  // #ifndef H5
  options.url = baseUrl + options.url
  // #endif
}

baseUrl 在模块加载时求值一次(:9 const baseUrl = getEnvBaseUrl())。VITE_APP_PROXY_ENABLE 当前是 false(env/.env:33),所以 H5 分支实际走的也是 baseUrl + url——移动端不走 Vite proxy,三端都直连后端。

getEnvBaseUrl()(src/utils/index.ts:141-160)有一个只在微信小程序生效的分支:

ts
if (isMpWeixin) {
  const { miniProgram: { envVersion } } = uni.getAccountInfoSync()
  const weixinBaseUrlMap = {
    develop: import.meta.env.VITE_SERVER_BASEURL__WEIXIN_DEVELOP,
    trial:   import.meta.env.VITE_SERVER_BASEURL__WEIXIN_TRIAL,
    release: import.meta.env.VITE_SERVER_BASEURL__WEIXIN_RELEASE,
  }
  baseUrl = weixinBaseUrlMap[envVersion] || baseUrl
}

即微信小程序可以按开发版 / 体验版 / 正式版分别指向不同后端。三个变量当前在 env/.env:28-30 是注释掉的,所以回退到 VITE_SERVER_BASEURL。要启用时注意:小程序还得把域名加进平台白名单(见多端构建与发布)。

超时统一 60 s(interceptor.ts:50)。

2. 出站:Authorization 与两个租户头 ​

interceptor.ts:55-80:

ts
const tokenStore = useTokenStore()
const token = tokenStore.updateNowTime().validToken
let isToken = (options!.header || {}).isToken === false
for (const v of whiteList) {
  if (options.url && options.url.includes(v)) { isToken = false; break }
}
if (!isToken && token) {
  options.header.Authorization = `Bearer ${token}`
}
if (tenantEnable && tenantEnable === 'true') {
  const tenantId = useUserStore().tenantId
  if (tenantId) options.header['tenant-id'] = tenantId
  const visitTenantId = useUserStore().visitTenantId
  if (token && visitTenantId) options.header['visit-tenant-id'] = visitTenantId
}
  • updateNowTime() 先把 nowTime 刷成当前时间,逼 isTokenExpired 这个 computed 重算(store/token.ts:51-54)。少了这一步,validToken 会拿着旧的时间戳做过期判断。
  • tenant-id 是多租户的身份,visit-tenant-id 是租户穿透(管理员代看另一个租户的数据),与 Web 端 src/views/system/user/components/tenant-visit-picker.vue 对应的移动端选择器在 src/pages/user/components/tenant-visit-picker.vue。
  • VITE_APP_TENANT_ENABLE=true(env/.env:62),所以这两个头是开着的。

🔴 whiteList 是死代码,而且它的匹配方式本身也有问题

interceptor.ts:12-16 的注释写着「白名单列表,不需要传递 token 字段」,列了 /login、/refresh-token、/system/tenant/get-id-by-name 三项。但 :58 到 :68 的逻辑是反的:

ts
let isToken = (options!.header || {}).isToken === false  // 常规请求 → false
for (...) { if (命中白名单) { isToken = false; break } }  // 命中后仍是 false,等于什么都没做
if (!isToken && token) { /* 加上 Authorization */ }       // !false → 加

变量名叫 isToken,实际语义是「不要带 token」:只有调用方显式传 header: { isToken: false } 时它才为 true,才会跳过 Authorization。命中白名单只是把它又赋了一遍 false——白名单永远不会抑制 token。

为什么现在没出事:validToken 在 accessToken 过期时返回空串(store/token.ts:255-267),而白名单那三个接口恰好都在「没有有效 token」的场景下调用,if (!isToken && token) 的第二个条件天然为假。所以是潜在缺陷,不是当前故障。

为什么不能顺手"修好":匹配用的是 options.url.includes(v)(:61),是子串匹配。/login 会命中 /system/login-log/page——那是移动端真实在用的接口(src/api/system/login-log/index.ts:20)。一旦把赋值方向改对,「登录日志」页会立刻开始发无 token 请求、拿 401。要修就得同时把匹配改成路径前缀精确比对。

改之前先跑一遍:grep -rn "'/login\|/refresh-token\|get-id-by-name" src/api/,确认受影响面。

3. 两个"已登录"判据,故意不一致 ​

判据定义用在哪双 token 模式下看过期吗
validTokenstore/token.ts:255-267 的 computed请求头看。过期返回 ''
hasLoginstore/token.ts:288-297(hasValidLogin)导出为 hasLogin(:325)路由拦截 router/interceptor.ts:95不看。只判 hasLoginInfo(有没有 accessToken)

hasValidLogin 里 :291-293 的注释解释了原因:

双令牌场景下,以刷新令牌过期为准。而刷新令牌是否过期,通过请求时返回 401 来判断(由于后端 refreshToken 不返回过期时间)。如果不这么做:访问令牌过期时(刷新令牌没过期),会导致刷新界面时,直接认为是令牌过期,导致跳转到登录界面。

配套的还有 isRefreshTokenExpired(:97-109)——它硬编码 return false,因为后端 /system/auth/refresh-token 的响应 IDoubleTokenRes(src/api/types/login.ts:11-17)只有 accessToken / refreshToken / expiresTime 三个字段,没有 refreshToken 的过期时间。所以「刷新令牌到底还能不能用」这件事,只能靠实际发一次刷新请求、看它是否 401 来回答。

单 token 模式还留着,但没启用

VITE_AUTH_MODE = 'double'(env/.env:56)→ isDoubleTokenMode = true(src/utils/index.ts:179)。tokenInfoState(store/token.ts:28-39)按模式给两种形状,isSingleTokenRes / isDoubleTokenRes 两个类型守卫在 src/api/types/login.ts:98-109。切回 single 后:refreshToken() 直接抛错(:223-226)、401 不再刷新而是直接跳登录(http.ts:116-120)。本项目未验证过单 token 模式。

4. 入站:401 的无感刷新与并发队列 ​

http.ts:10-20 维护四个模块级状态:refreshing(是否正在刷新)、taskQueue(等刷新的请求)、refreshFailedReason(刷新失败原因)、loginExpiredHandling(是否正在处理登录失效)。

对应代码:http.ts:100-169。三个要点:

  1. 并发合并:多个请求同时 401 时,只有第一个触发刷新(refreshing 标志 :138),其余全部入队(:129-135),刷新成功后 replayTaskQueue()(:30-40)用 Promise.allSettled 一次性重放。
  2. 防无限刷新:重放时给 options 打上 __isRefreshTokenRetry: true(:36),再 401 就直接走 handleLoginExpired(:108-111)。
  3. 重放窗口::144 的注释「重放期间晚到的旧 401 也纳入同一轮,避免再次刷新」——replayTaskQueue 用 while (taskQueue.length) 循环,就是为了把重放过程中新入队的请求一起处理掉。

handleLoginExpired(:43-69):loginExpiredHandling 上锁 → nextTick → 关旧 toast → 提示「登录已过期,请重新登录」→ tokenStore.logout() → 2 秒后跳登录页,并带上 ?redirect=<当前完整路径>(:59-65,注释注明是「优化 by 芋艿」)。

5. 入站:业务码与三种"原样返回" ​

http.ts:172-192,HTTP 2xx 之后还有三道关:

ts
if (options.original)        return resolve(responseData)        // :174-176 完全原样
if (code !== 0 && code !== 200) { toast(...); return reject(responseData) }   // :178-187
if (options.returnRawResponse) return resolve(responseData)      // :188-190 业务码已过,但返回整个信封
return resolve(responseData.data)                                // :191 默认只给 data
  • original 用于响应形状不受后端信封约束的接口,实测只有滑块验证码两个:getCode / checkCaptcha(src/api/login.ts:79、:84)。
  • returnRawResponse 用于需要 total 之外字段的场景。
  • :185 的注释「reject 替代原本的 resolve,避免调用的地方以为请求成功」是芋道改的——上游 unibest 这里原本是 resolve。这个差异很关键:业务码非 0 时移动端是 reject,所以调用方必须 try/catch 或 .catch(),否则会变成未捕获的 Promise rejection。这正是移动端 17 个工作台页面抛未捕获异常那一类问题的成因之一。
  • hideErrorToast: true 可以关掉自动 toast,由调用方自己提示。实测 src/api/login.ts:144-146 的 socialLogin 用了它。

网络层失败(fail)统一提示「网络错误,换个网络试试」(:203-209)并 reject。

6. 路由级登录拦截 ​

router/interceptor.ts:151-158 把同一个 navigateToInterceptor 挂到 4 个导航 API 上:navigateTo / reLaunch / redirectTo / switchTab。

策略是白名单式(router/config.ts:8:LOGIN_STRATEGY = LOGIN_STRATEGY_MAP.DEFAULT_NEED_LOGIN,注释「edit by 芋艿:管理后台,默认需要登录」)。放行判定顺序(:51-147):

  1. plugin:// 开头的插件页直接跳过校验(:65-68);
  2. 相对路径解析(:71-76):不以 / 开头时,用当前页 route 的目录拼绝对路径;
  3. 路由存在性校验(:79-83):getAllPages() 里没有这个 path 就 console.warn('路由不存在') 并跳 NOT_FOUND_PAGE(/pages-core/error/404),返回 false 阻断;
  4. 小程序且 LOGIN_PAGE_ENABLE_IN_MP=false 时全部放行(:87-89)——本项目该值是 true(config.ts:47),所以这条不生效;
  5. tokenStore.hasLogin 为真:非登录页直接放行;已在登录页则反向跳走(:98-107),tabbar 页用 switchTab、其余用 navigateTo;
  6. 未登录:judgeIsExcludePath(path) 命中白名单则放行,否则 toLoginPage({ queryString: '?redirect=...' }) 并返回 false。

白名单有两个来源:

  • EXCLUDE_LOGIN_PATH_LIST(config.ts:36-41)——里面只有两条上游示例值 /pages/xxx/index、/pages-sub/xxx/index,都不是真实页面;
  • definePage({ excludeLoginPath: true }) ——实测 4 个页面用了它:src/pages-core/auth/{code-login,forget-password,register,social-login/index}.vue。

judgeIsExcludePath(interceptor.ts:17-34)对这两者的合并方式在 dev 与 prod 下不同:dev 每次都重新 getAllPages('excludeLoginPath')(:32,注释「否则新配置就不会生效」),prod 只在第一次合并进 EXCLUDE_LOGIN_PATH_LIST 后置 excludeListInited = true(:21-29)。

登录页 /pages-core/auth/login 自己不在白名单里

LOGIN_PAGE(config.ts:14)没被 definePage 标 excludeLoginPath,靠的是 :127-129 那条 if (path === LOGIN_PAGE) return allowRoute(path) 的硬编码放行。所以新增一个免登录页时,不要指望它像登录页一样被特判——必须在页面的 definePage 里写 excludeLoginPath: true。

toLoginPage(src/utils/toLoginPage.ts)有两处非显然行为::24 整体被 debounce(..., 500) 包着(防止多个并发 401 各跳一次),:28-30 一旦带 queryString 就强制 reLaunch(注释:「避免使用 navigateTo 导致页面数据不会刷新」)。要立即跳转可用 toLoginPage.flush()。

7. 权限码判定 ​

fetchUserInfo()(store/user.ts:93)打 /system/auth/get-permission-info,把 roles / permissions 存进 store(:53-54)。登录后由 _postLogin 串起来(store/token.ts:115-127):setTokenInfo → fetchUserInfo → navStore.loadNavTree(true)(await,工作台落地页依赖它)→ dictStore.loadDictCacheWithRetry()(void,不 await)。

页面里判权限用 useAccess()(src/hooks/useAccess.ts):hasAccessByRoles(roles) / hasAccessByCodes(codes),都是任一命中即为真(OR 语义,:18-19、:30-31)。这个 OR 语义与后端 system_nav_node.permissions 的挂接方式一致,见服务端驱动导航。

前端权限判定只是展示层

permissions 从后端 /system/auth/get-permission-info 取,判定的权威仍在后端。移动端没有路由级权限拦截(不像 Web 端按 system_menu 动态生成路由)——它的"看不到"是靠导航树不下发实现的,直接 uni.navigateTo 到某个页面路径依然能进,只是接口会 403。这一点与 Web 端有结构性差别,详见权限体系总览。

8. API 加解密:开关是开的,通路是断的 ​

三处状态拼起来才知道真相:

位置状态
env/.env:73-77VITE_APP_API_ENCRYPT_ENABLE = true、算法 AES、REQUEST_KEY / RESPONSE_KEY 两个 32 位密钥明文写在仓库里
src/utils/encrypt.ts:156-158encryptRequest() 里 if (!API_ENCRYPT_ENABLE) return data——开关是开的,函数本身可用
src/http/interceptor.ts:83if (options.isEncrypt) { ... }——必须由调用方显式传 isEncrypt
全仓调用点🔴 grep -rn "isEncrypt" src 除 http/types.ts(类型定义)与 http/interceptor.ts(消费点)外,零个调用方
后端 ainbenlev.api-encrypt.enable: false(enlev-server/src/main/resources/application.yaml:291,注释:「默认关闭,避免低版本 JDK 8(8u120 以下)启动时 bcprov 的 JCE 认证报错」),@ConditionalOnProperty(...havingValue="true") → ApiEncryptFilter 根本没注册

结论:请求加密完全没在跑;响应解密虽然代码活着(http.ts:84-94),但因为后端 filter 未注册、不会有人往响应里塞 X-Api-Encrypt: true,同样不会触发。

Web 端是同一个形状:ainb-ui-admin-vue3/.env:28-32 用的是同一对密钥,src/config/axios/service.ts:86 也是靠 headers.isEncrypt 触发。所以这不是移动端独有的遗漏,是两端一致的"能力已备、通路未接"。

真要启用需要三件事同时做:后端把 enable 改 true 并给目标 Controller 加 @ApiEncrypt(request=..., response=...)、前端在对应 API 调用里传 isEncrypt: true、并且把密钥从仓库里挪出去——现在这对 AES 密钥同时明文存在于 ainb/enlev-server/src/main/resources/application.yaml:293-294、ainb-ui-admin-vue3/.env:31-32、ainb-ui-admin-uniapp/env/.env:76-77 三处。前端密钥天然要下发到浏览器,本就无秘密可言,但它意味着"加密"只是防中间人看到明文、不能当作访问控制。别把它当安全措施来规划。

AES 用的是 ECB 模式 + Pkcs7(encrypt.ts:37-38、:67-68),密钥强制 32 位(:31-33 会抛错)。ECB 不带 IV、相同明文块产生相同密文块,这是上游的实现选择,改它要连带改后端。

9. 文件上传与 SSE ​

  • 上传有两套实现,别找错:

    • src/api/infra/file/index.ts:69-100 的 uploadFile() —— 直接调 uni.uploadFile,URL 拼成 ${getEnvBaseUrl()}/infra/file/upload(:78),并自己塞 Authorization(用 await tryGetValidToken(),:75)与 tenant-id;
    • src/utils/uploadFile.ts:66 的 uploadFileFromPath() —— 按 VITE_UPLOAD_TYPE 分流(:75-78),server 走上面那条,client 走 S3 预签名直传。当前 env/.env:38 是 server,client 分支未启用。另有 useUpload() 组合式函数(:171)。

    因为 uni.uploadFile 也挂了同一个 httpInterceptor(interceptor.ts:106),拦截器会把 header 再写一遍。两者取值来源不同(拦截器用 validToken,api/infra/file 用 tryGetValidToken),过期场景下拦截器的 if (!isToken && token) 因 token 为空串而不覆盖,于是手动那份生效——这是巧合而非设计,改任一侧都要重测过期上传。 🔴 没有 VITE_UPLOAD_BASEURL 这个变量,env/ 下不存在该键,别照 Web 端的印象去找。 🔴 同一句 ${getEnvBaseUrl()}/infra/file/upload 还被复制到了 src/pages-bpm/processInstance/detail/audit/index.vue:353 与 src/pages-im/home/composables/useMediaUploader.ts:71(后者用的是原生 fetch,完全绕过拦截器)。改上传口径要三处一起改。

  • SSE:src/http/sse.ts 不走 uni.request,用的是 RequestTask.onChunkReceived(:11-13 的 ChunkRequestTask 接口),自己按 \r?\n\r?\n 切块、按 data: 前缀取行(:37-45),超时 10 分钟(:17)。它不经过 httpInterceptor::151 自己取 getEnvBaseUrl()、:157 与 :270 用 await useTokenStore().tryGetValidToken() 拿 token(注意是 tryGetValidToken,会主动刷新,比拦截器用的 validToken 更稳),再手动拼 Authorization(:218、:289)。AI 对话的流式输出走这条路。

数据模型 ​

登录态在客户端的存储分两处:

存储位置键内容谁写
Pinia + persisttoken storetokenInfo:双 token 模式下是 { accessToken, refreshToken, expiresTime }setTokenInfo()(store/token.ts:57-76)
Pinia + persistuser storetenantId / visitTenantId / roles / permissions / 用户信息fetchUserInfo()
uni.setStorageSyncaccessTokenExpireTimeaccessToken 过期的绝对毫秒时间戳setTokenInfo() 的 :72(双 token)/ :66(单 token)
内存(不持久化)nowTime当前时间戳,用于触发过期 computed 重算updateNowTime()

refreshTokenExpireTime 被注释掉了,别去找

store/token.ts:71-73、:102-106、:202 三处都有被注释掉的 refreshToken 过期时间读写代码,注释统一写着「add by 芋艿:目前后端没有返回 refreshToken 的过期时间」。所以 uni.getStorageSync('refreshTokenExpireTime') 永远拿不到值,isRefreshTokenExpired 永远 false。

登出清理是全量的(store/token.ts:196-215 的 finally):accessTokenExpireTime 与 token 两个 storage 键、tokenInfo 重置、uni.$emit('auth:logout')、userStore.clearUserInfo()、dictStore.clearDictCache()、navStore.clearNav()。注意它在 finally 里——即使 /system/auth/logout 接口失败也照清(:189-195 把异常吞掉只打 log)。

接口清单 ​

登录态相关的全部后端接口(均在 src/api/login.ts):

方法路径说明
POST/system/auth/login账号密码登录,返回 IAuthLoginRes
POST/system/auth/register注册即登录
POST/system/auth/sms-login短信登录
POST/system/auth/send-sms-code发送短信验证码
POST/system/auth/reset-password短信重置密码
POST/system/auth/refresh-token?refreshToken=xxx刷新,refreshToken 走 query 不走 body(:124)
POST/system/auth/logout登出
GET/system/auth/get-permission-info取 roles / permissions / 用户信息
GET/system/auth/social-auth-redirect取三方授权地址
POST/system/auth/social-login三方快捷登录,带 hideErrorToast
POST/system/captcha/get · /system/captcha/check滑块验证码,两个都用 original: true
GET/system/tenant/simple-list · /system/tenant/get-by-website租户列表 / 按域名取租户

AUTH_THIRD_LOGIN_NOT_BIND_CODE = 1002000005(:9)是「三方账号尚未绑定」的业务码,社交登录页据此走绑定流程。

怎么扩展 ​

加一个 API:在 src/api/<module>/<feature>/index.ts 里

ts
import { http } from '@/http/http'
import type { PageResult } from '@/http/types'

export interface FooVO { id: number, name: string }

export function getFooPage(params: { pageNo: number, pageSize: number }) {
  return http.get<PageResult<FooVO>>('/system/foo/page', params)
}
export function createFoo(data: FooVO) {
  return http.post<number>('/system/foo/create', data)
}

GET 的第二个参数是 query(会被 stringifyQuery 拼到 URL),POST 的第二个是 body、第三个才是 query。这个签名顺序来自 httpGet(url, query, header, options) / httpPost(url, data, query, header, options)(http.ts:222、:241),与 axios 不同,别按 axios 的习惯传。

要自己处理错误提示:传 { hideErrorToast: true } 作为最后一个参数。

要拿整个响应信封:{ returnRawResponse: true }(业务码仍会校验);连业务码都不想校验则用 { original: true }。

要发不带 token 的请求:header: { isToken: false }——这是唯一有效的方式,往 whiteList 里加路径没用(见第 2 节的 danger 块)。

要免登录页面:在页面的 definePage 里加 excludeLoginPath: true。

测试 ​

🔴 移动端仓库没有任何单元测试(package.json 的 scripts 里只有 type-check / lint / check:color,没有 test 相关条目;全仓无 *.spec.ts / *.test.ts)。这一页描述的所有行为都是读源码 + 实测 H5 运行时得出的,没有自动化回归保护。

改这一层时的最低验证:

bash
cd ainb-ui-admin-uniapp
pnpm type-check        # vue-tsc,会覆盖 http/ 与 store/ 的类型
pnpm lint              # eslint 增量

再手动跑三条路径:① 正常登录 → 工作台加载(覆盖 _postLogin 的四步串联);② 把 accessTokenExpireTime 手动改小到过去 → 刷新页面 → 观察是否走无感刷新而不是跳登录(覆盖 hasValidLogin 的"故意不看过期");③ 把 refreshToken 也改坏 → 观察是否正确跳登录页并带 ?redirect=(覆盖 handleLoginExpired)。

Web 端有 59 个 vitest 单测,移动端一个都没有——不要把 Web 端的测试经验套过来。

坑与注意 ​

  • 业务码非 0 是 reject,不是 resolve(http.ts:186)。调用方不 catch 就是未捕获 rejection。这是移动端页面异常的主要来源之一。
  • baseUrl 在模块加载时求值一次(interceptor.ts:9)。改了 env/.env* 不重启 dev server,请求还是打老地址——与环境准备里那条 <title> 陷阱同源。
  • validToken 与 hasLogin 判据不同,别用 hasLogin 去决定要不要发请求,也别用 validToken 去决定要不要跳登录页。
  • updateNowTime() 不能省。直接读 tokenStore.validToken 会拿缓存的 computed 结果。
  • toLoginPage 有 500 ms 防抖,紧跟其后的代码不会等它跳完。
  • 小程序端 uni.getAccountInfoSync() 只在真机/开发者工具里有值,H5 下调用会抛错——getEnvBaseUrl() 用 isMpWeixin 条件编译隔开了(utils/index.ts:146),自己写类似逻辑时记得也隔。
  • SSE 不经过 httpInterceptor,token 与 baseUrl 要自己处理,改动 http.ts 时不会自动惠及它。
  • EXCLUDE_LOGIN_PATH_LIST 里那两条是上游示例值,不是真页面,别以为白名单已经在用。

→ 本节是贴着上下文的提醒;按症状查的移动端全域清单见移动端坑位,跨端清单见坑位索引。

未证实 ​

  • 单 token 模式(VITE_AUTH_MODE=single)从未在本项目验证过。代码路径完整,但 refreshToken() 抛错、401 直接跳登录这两条分支没有实测记录。
  • API 加解密启用后的端到端行为未验证。两端密钥一致、算法都是 AES,但 enable=false 意味着这条通路从未跑通过;ApiEncryptFilter 对 multipart/form-data(文件上传)的处理方式未读源码确认。
  • 无感刷新队列在多 tab / 多 webview 场景下的行为:refreshing、taskQueue 都是模块级变量,H5 下每个 tab 各有一份,所以两个 tab 可能各自刷一次 token。后端 refreshToken 是否允许多次刷新(旧 token 是否立即失效)未确认。
  • visit-tenant-id 穿透的后端校验链未在本页展开,见权限体系。

相关页面 ​