请求封装与登录态
适用端:移动端 | 类型:机制
移动端没有 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-212 | http() 主体;:222-283 是 httpGet/httpPost/httpPut/httpDelete 与 http.get/post/put/delete 别名 |
src/http/interceptor.ts:19-99 | httpInterceptor.invoke();:101-108 是 install() |
src/http/types.ts:4-16 | CustomRequestOptions:query / hideErrorToast / original / returnRawResponse / isEncrypt / __isRefreshTokenRetry |
src/http/tools/enum.ts | ResultEnum(Success0 / Success200) |
src/http/tools/queryString.ts | stringifyQuery(),query 对象 → URL 串 |
src/http/sse.ts | AI 流式请求,走 onChunkReceived 而非 uni.request,超时 10 分钟(:17) |
src/store/token.ts | Pinia token store:登录 / 登出 / 刷新 / validToken / hasLogin,persist: true(:340) |
src/store/user.ts | tenantId / 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-179 | getEnvBaseUrl() / getEnvBaseUrlRoot() / isDoubleTokenMode |
src/utils/encrypt.ts:143+ | ApiEncrypt 门面 + AES / RSA 两个静态类 |
src/utils/toLoginPage.ts | 跳登录页,带 500 ms 防抖(:24、:48) |
src/hooks/useAccess.ts | hasAccessByRoles() / hasAccessByCodes() |
核心机制
1. 出站:baseUrl 拼接与 H5 的代理开关
interceptor.ts:32-48:
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)有一个只在微信小程序生效的分支:
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:
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 的逻辑是反的:
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 模式下看过期吗 |
|---|---|---|---|
validToken | store/token.ts:255-267 的 computed | 请求头 | 看。过期返回 '' |
hasLogin | store/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。三个要点:
- 并发合并:多个请求同时 401 时,只有第一个触发刷新(
refreshing标志:138),其余全部入队(:129-135),刷新成功后replayTaskQueue()(:30-40)用Promise.allSettled一次性重放。 - 防无限刷新:重放时给 options 打上
__isRefreshTokenRetry: true(:36),再 401 就直接走handleLoginExpired(:108-111)。 - 重放窗口:
: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 之后还有三道关:
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 默认只给 dataoriginal用于响应形状不受后端信封约束的接口,实测只有滑块验证码两个: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):
plugin://开头的插件页直接跳过校验(:65-68);- 相对路径解析(
:71-76):不以/开头时,用当前页route的目录拼绝对路径; - 路由存在性校验(
:79-83):getAllPages()里没有这个 path 就console.warn('路由不存在')并跳NOT_FOUND_PAGE(/pages-core/error/404),返回false阻断; - 小程序且
LOGIN_PAGE_ENABLE_IN_MP=false时全部放行(:87-89)——本项目该值是true(config.ts:47),所以这条不生效; tokenStore.hasLogin为真:非登录页直接放行;已在登录页则反向跳走(:98-107),tabbar 页用switchTab、其余用navigateTo;- 未登录:
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-77 | VITE_APP_API_ENCRYPT_ENABLE = true、算法 AES、REQUEST_KEY / RESPONSE_KEY 两个 32 位密钥明文写在仓库里 |
src/utils/encrypt.ts:156-158 | encryptRequest() 里 if (!API_ENCRYPT_ENABLE) return data——开关是开的,函数本身可用 |
src/http/interceptor.ts:83 | if (options.isEncrypt) { ... }——必须由调用方显式传 isEncrypt |
| 全仓调用点 | 🔴 grep -rn "isEncrypt" src 除 http/types.ts(类型定义)与 http/interceptor.ts(消费点)外,零个调用方 |
后端 ainb | enlev.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 + persist | token store | tokenInfo:双 token 模式下是 { accessToken, refreshToken, expiresTime } | setTokenInfo()(store/token.ts:57-76) |
Pinia + persist | user store | tenantId / visitTenantId / roles / permissions / 用户信息 | fetchUserInfo() |
uni.setStorageSync | accessTokenExpireTime | accessToken 过期的绝对毫秒时间戳 | 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 里
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 运行时得出的,没有自动化回归保护。
改这一层时的最低验证:
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穿透的后端校验链未在本页展开,见权限体系。
相关页面
- 移动端总览 —— 三仓共用同一套
/admin-api与权限判定 - 移动端环境准备与启动 ——
VITE_AUTH_MODE/VITE_APP_TENANT_ENABLE等变量的取值 - 工程结构与约定式路由 ——
src/http//src/store//src/router/的位置与自动导入 - 服务端驱动导航:system_nav_node 全链路 ——
loadNavTree在_postLogin里被 await 的原因 - 移动端页面开发范式 —— 页面里怎么调 API、怎么用
useAccess - axios 封装与 API 约定 —— Web 端的对应页,两端信封与业务码口径一致
- 移动端坑位
