前言
很多 Android 项目里都会用 Token 做登录认证。
最常见的方式是:
用户登录 ↓ 服务端返回 token ↓ 客户端保存 token ↓ 后续请求在 Header 中携带 token这种方式在早期项目里很常见,也能跑。
但是随着项目复杂度上来,就会遇到一系列问题:
- AccessToken 过期怎么办?
- 接口返回 401 怎么统一处理?
- RefreshToken 应该在哪里刷新?
- 刷新成功后,原来的请求怎么继续执行?
- 多个接口同时返回 401 怎么办?
- 刷新 Token 时网络异常,要不要直接踢用户下线?
- RefreshToken 失效和网络异常能不能混在一起处理?
如果这些问题没处理好,线上就容易出现:
- 频繁跳登录
- 多个接口重复刷新 Token
- RefreshToken 被重复使用导致失效
- 网络波动时误判为登录失效
- 代码里到处都是 token 判断逻辑
所以,真正企业级的网络框架,不只是会用 Retrofit + OkHttp。
更重要的是要有一套完整的认证体系:
Token 存储 请求拦截 响应拦截 401 自动续期 RefreshToken 刷新 请求重放 异常分类 并发刷新治理这篇文章就从一个真实项目出发,完整梳理一套 Android 企业级双 Token 认证方案。
一、为什么需要双 Token 机制?
传统单 Token 方案一般是:
AccessToken客户端请求接口时,在 Header 里携带:
Authorization: Bearer xxx服务端校验通过,请求成功。
但是 AccessToken 不可能永久有效。
如果 AccessToken 设置得太长,安全性差。
如果 AccessToken 设置得太短,用户体验差。
于是企业项目里通常会采用双 Token 机制:
AccessToken :访问业务接口,生命周期较短 RefreshToken :用于刷新 AccessToken,生命周期较长整体流程是:
用户登录 ↓ 服务端返回 accessToken + refreshToken ↓ 客户端保存 ↓ 业务请求携带 accessToken ↓ accessToken 过期 ↓ 服务端返回 401 ↓ 客户端使用 refreshToken 获取新的 accessToken ↓ 保存新的 token ↓ 重新请求刚才失败的接口也就是:
AccessToken 负责访问 RefreshToken 负责续命二、整体架构设计
这套网络认证体系主要由几个核心类组成:
auth/ ├── AuthApi.kt ├── RefreshResult.kt ├── RefreshRetrofit.kt └── TokenRefresher.kt manager/ └── TokenManager.kt interceptor/ ├── TokenAuthInterceptor.kt └── PublicParameterInterceptor.kt整体架构如下:
业务请求 ↓ PublicParameterInterceptor ↓ TokenAuthInterceptor ↓ 添加 accessToken ↓ 请求服务器 ↓ 服务器返回 401 ↓ TokenAuthInterceptor 捕获 401 ↓ TokenRefresher 使用 refreshToken 刷新 ↓ RefreshResult 返回刷新结果 ↓ 刷新成功:重放原请求 刷新失败:清空 token,跳登录 网络异常:不清 token,提示网络异常核心链路可以理解成:
TokenManager ↓ TokenAuthInterceptor ↓ TokenRefresher ↓ RefreshRetrofit ↓ RefreshResult三、TokenInfo:完整保存 Token 信息
首先定义服务端返回的 Token 数据结构。
这里不要只保存 accessToken,而是应该把服务端返回的完整 Token 信息保存下来。
package com.xxx.lib_net.auth import com.google.gson.annotations.SerializedName import com.xxx.lib_net.response.BaseResponse import retrofit2.Call import retrofit2.http.POST import retrofit2.http.Query /** * * 认证相关API接口定义 * * @author mark.wu * @date 2026/5/28 9:13 * @desc */ interface AuthApi { @POST("/system/auth/refresh-token") fun refreshToken( @Query("refreshToken") refreshToken: String ): Call<BaseResponse<TokenInfo>> } data class TokenInfo( @SerializedName("userId") val userId: Long, @SerializedName("accessToken") val accessToken: String, @SerializedName("refreshToken") val refreshToken: String, @SerializedName("expiresTime") val expiresTime: Long )这里有几个字段:
userId :当前用户 ID accessToken :业务接口请求使用 refreshToken :accessToken 过期后刷新使用 expiresTime :accessToken 过期时间为什么建议保存完整的 TokenInfo?
因为 Token 本质上不是几个孤立字符串,而是一组认证状态。
如果只保存:
accessToken refreshToken后面服务端再加:
expiresTime tokenType scope deviceId你就要不断改方法签名。
更好的方式是:
TokenManager.saveToken(tokenInfo)而不是:
saveAccessToken() saveRefreshToken() saveExpiresTime()这样结构更完整,也更适合后续扩展。
四、TokenManager:统一管理 Token 状态
TokenManager负责:
Token 内存缓存 Token 持久化 读取 accessToken 读取 refreshToken 读取 expiresTime 判断是否即将过期 清空 Token完整代码如下:
package com.xxx.lib_net.manager import com.xxx.lib_net.auth.TokenInfo import com.xxx.lib_net.constant.KEY_ACCESS_TOKEN import com.xxx.lib_net.constant.KEY_EXPIRES_TIME import com.xxx.lib_net.constant.KEY_REFRESH_TOKEN import com.xxx.lib_net.constant.KEY_USER_ID import com.tencent.mmkv.MMKV /** * Token管理类 * * @author mark.wu * @date 2026/5/27 10:00 * * accessToken :业务接口请求使用 * refreshToken :accessToken 过期后刷新使用 * expiresTime :提前刷新优化使用,最终是否失效以后端 401 为准 */ object TokenManager { // 内存缓存 @Volatile private var tokenInfoCache: TokenInfo? = null fun saveToken(tokenInfo: TokenInfo) { tokenInfoCache = tokenInfo val mmkv = MMKV.defaultMMKV() mmkv.encode(KEY_USER_ID, tokenInfo.userId) mmkv.encode(KEY_ACCESS_TOKEN, tokenInfo.accessToken) mmkv.encode(KEY_REFRESH_TOKEN, tokenInfo.refreshToken) mmkv.encode(KEY_EXPIRES_TIME, tokenInfo.expiresTime) } fun getTokenInfo(): TokenInfo? { tokenInfoCache?.let { return it } val mmkv = MMKV.defaultMMKV() val accessToken = mmkv.decodeString(KEY_ACCESS_TOKEN, "") ?: "" val refreshToken = mmkv.decodeString(KEY_REFRESH_TOKEN, "") ?: "" val expiresTime = mmkv.decodeLong(KEY_EXPIRES_TIME, 0L) val userId = mmkv.decodeLong(KEY_USER_ID, 0L) if (accessToken.isEmpty() || refreshToken.isEmpty()) { return null } return TokenInfo( userId = userId, accessToken = accessToken, refreshToken = refreshToken, expiresTime = expiresTime ).also { tokenInfoCache = it } } fun getAccessToken(): String { return getTokenInfo()?.accessToken ?: "" } fun getRefreshToken(): String { return getTokenInfo()?.refreshToken ?: "" } fun getExpiresTime(): Long { return getTokenInfo()?.expiresTime ?: 0L } fun hasToken(): Boolean { return getTokenInfo() != null } /** * accessToken 是否即将过期 * * 这里只是“本地预判”,用于提前刷新优化。 * 最终是否真的失效,仍然以后端返回 401 为准。 */ fun isAccessTokenExpiringSoon( aheadTimeMillis: Long = 2 * 60 * 1000L ): Boolean { val expiresTime = getExpiresTime() if (expiresTime <= 0L) { return false } val now = System.currentTimeMillis() return now >= expiresTime - aheadTimeMillis } fun clearToken() { tokenInfoCache = null val mmkv = MMKV.defaultMMKV() mmkv.removeValueForKey(KEY_USER_ID) mmkv.removeValueForKey(KEY_ACCESS_TOKEN) mmkv.removeValueForKey(KEY_REFRESH_TOKEN) mmkv.removeValueForKey(KEY_EXPIRES_TIME) } }这里有几个关键点。
1. 内存缓存 + MMKV 持久化
@Volatile private var tokenInfoCache: TokenInfo? = null内存缓存是为了减少频繁读取 MMKV。
应用进程还在时,优先读内存。
进程被杀后,再从 MMKV 恢复。
2. accessToken 和 refreshToken 必须一起判断
if (accessToken.isEmpty() || refreshToken.isEmpty()) { return null }因为双 Token 体系中,如果只有 accessToken,没有 refreshToken,续期链路是不完整的。
3. expiresTime 只做优化,不做最终依据
fun isAccessTokenExpiringSoon(...)这个方法可以提前判断 accessToken 是否快过期。
但是要注意:
expiresTime 依赖本地系统时间而用户可能修改手机时间。
所以它只能作为优化项。
真正判断 token 是否失效,最终还是以后端返回 401 为准。
可以记住一句话:
expiresTime 是预判 401 是裁决五、RefreshResult:不要用 null 表达所有失败
很多项目在刷新 Token 时会这样写:
fun refreshToken(): String? { return try { // 成功返回新 token } catch (e: Exception) { null } }这种写法最大的问题是:
null 到底代表什么?可能是:
refreshToken 失效 网络断开 请求超时 服务器 500 JSON 解析失败 返回 data 为空如果全部用null表达,外层就只能统一当成登录失效。
这会导致一个严重问题:
用户只是网络异常,却被踢下线所以这里设计一个RefreshResult:
package com.sqx.lib_net.auth sealed class RefreshResult { data class Success( val accessToken: String ) : RefreshResult() /** * refreshToken 失效 / 后端明确拒绝刷新 */ data class TokenExpired( val code: Int, val message: String? ) : RefreshResult() /** * 网络异常、超时、JSON 解析异常等 */ data class NetworkError( val throwable: Throwable ) : RefreshResult() }这样刷新结果就被明确分成三类:
Success :刷新成功 TokenExpired :refreshToken 失效,登录态失效 NetworkError :网络异常,不应该直接踢下线这一步非常关键。
它把:
登录状态问题和:
网络环境问题彻底分开了。
六、RefreshRetrofit:刷新 Token 必须使用独立 Retrofit
刷新 Token 的接口,不能使用主 Retrofit。
为什么?
因为主 Retrofit 通常会配置:
TokenAuthInterceptor如果 refreshToken 接口也走主拦截器,可能出现:
业务接口返回 401 ↓ TokenAuthInterceptor 调用 refreshToken 接口 ↓ refreshToken 接口也被 TokenAuthInterceptor 拦截 ↓ 发现 accessToken 失效 ↓ 继续刷新 ↓ 死循环所以刷新 Token 要使用独立的 Retrofit。
package com.xxx.lib_net.auth import android.util.Log import com.xxx.lib_net.constant.ServerUrls import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import java.util.concurrent.TimeUnit /** * Token 刷新专用 Retrofit 工具类 * * 【重要说明】此工具类是独立的,不使用主应用的拦截器链 * 原因:token 刷新场景下,原有 token 可能已过期,不能通过 TokenAuthInterceptor 的校验 * * 配置特点: * - 独立的 OkHttpClient,不包含 TokenAuthInterceptor * - 包含日志拦截器,便于调试 token 刷新过程 * - 标准超时配置(10秒) * - 使用公共参数拦截器添加设备信息等 * * @author mark.wu * @date 2026/5/28 9:13 */ object RefreshRetrofit { private const val TAG = "RefreshRetrofit" /** * Token 刷新专用 API 服务 */ val api: AuthApi by lazy { Log.d(TAG, "初始化 Token 刷新专用 Retrofit") Retrofit.Builder() .baseUrl(ServerUrls.API_SERVER_URL) .client(createOkHttpClient()) .addConverterFactory(GsonConverterFactory.create()) .build() .create(AuthApi::class.java) } /** * 创建独立的 OkHttpClient * 【注意】不添加 TokenAuthInterceptor,因为刷新 token 时可能没有有效 token */ private fun createOkHttpClient(): OkHttpClient { return OkHttpClient.Builder() // 超时配置(与主应用保持一致) .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS) .writeTimeout(10, TimeUnit.SECONDS) // 添加日志拦截器(便于调试) .addInterceptor(createLoggingInterceptor()) .build() } /** * 创建日志拦截器 */ private fun createLoggingInterceptor(): HttpLoggingInterceptor { return HttpLoggingInterceptor { message: String -> Log.i("okhttp-refresh", message) }.apply { level = HttpLoggingInterceptor.Level.BODY } } }这里最重要的是:
不添加 TokenAuthInterceptor刷新 Token 本身就是为了解决 accessToken 失效问题。
所以它不能再依赖一个可能已经失效的 accessToken。
七、TokenRefresher:真正执行 Token 刷新
TokenRefresher是整套方案的核心之一。
它负责:
读取 refreshToken 调用刷新接口 保存新的 TokenInfo 返回 RefreshResult 处理并发刷新完整代码如下:
package com.xxx.lib_net.auth import android.util.Log import com.xxx.lib_net.manager.TokenManager /** * Token 刷新器 * * 负责在 accessToken 过期时,使用 refreshToken 获取新的 accessToken * 使用 synchronized 保证线程安全,避免并发刷新导致重复请求 * 锁内二次检查 * * @author mark.wu * @date 2026/5/27 10:00 */ object TokenRefresher { private const val TAG = "TokenRefresher" private val lock = Any() /** * 同步刷新 Token * @param oldAccessToken 旧的 accessToken,用于判断是否已经被其他线程刷新 * @return 刷新结果 */ fun refreshTokenSync(oldAccessToken: String): RefreshResult { synchronized(lock) { // ==================== 二次检查 ==================== // 可能已经被其它线程刷新过了 val latestAccessToken = TokenManager.getAccessToken() if (latestAccessToken.isNotEmpty() && latestAccessToken != oldAccessToken) { Log.d(TAG, "Token 已被其它线程刷新,直接复用") return RefreshResult.Success(accessToken = latestAccessToken) } // ==================== 开始真正刷新 ==================== // 1. 检查是否有 refreshToken val refreshToken = TokenManager.getRefreshToken() if (refreshToken.isEmpty()) { Log.w(TAG, "refreshToken 为空,无法刷新") return RefreshResult.TokenExpired(code = -1, message = "refreshToken 为空") } return try { // 2. 执行刷新请求 val response = RefreshRetrofit.api.refreshToken(refreshToken).execute() val body = response.body() // 3. 检查响应是否成功 if (response.isSuccessful && body?.isSuccess() == true) { val tokenInfo = body.data if (tokenInfo != null) { // 4. 保存新的 Token TokenManager.saveToken(tokenInfo) Log.d(TAG, "Token 刷新成功") RefreshResult.Success(accessToken = tokenInfo.accessToken) } else { Log.w(TAG, "Token 刷新失败: data为空") RefreshResult.TokenExpired(code = body.code, message = "Token数据为空") } } else { Log.w(TAG, "Token 刷新失败: code=${body?.code}, msg=${body?.message}") RefreshResult.TokenExpired(code = body?.code ?: response.code(), message = body?.message ?: response.message()) } } catch (e: Exception) { // 网络异常等情况 Log.e(TAG, "Token 刷新异常: ${e.message}", e) RefreshResult.NetworkError(e) } } } }这段代码有两个非常重要的点。
1. 为什么使用 execute() 同步请求?
因为 OkHttp 的 Interceptor 本身是同步链路。
在拦截器里:
val response = chain.proceed(request)本身就是同步阻塞调用。
当接口返回 401 后,我们需要:
刷新 Token ↓ 拿到新 Token ↓ 重新请求原接口 ↓ 返回最终 Response所以刷新 Token 也应该同步完成。
如果用异步enqueue(),拦截器无法等待回调结果。
所以这里使用:
.execute()是最稳妥的方案。
2. 为什么需要 synchronized?
因为 Android 项目里非常容易出现多个接口并发请求。
例如首页启动时可能同时请求:
用户信息 Banner 消息数量 系统配置 列表数据如果此时 accessToken 刚好过期,就会出现:
A 接口返回 401 B 接口返回 401 C 接口返回 401 D 接口返回 401 E 接口返回 401如果不加控制,每个接口都会去刷新 Token:
refreshToken × 5这会带来两个问题:
浪费网络资源 可能导致 RefreshToken 状态错乱尤其是很多后端会设计成:
RefreshToken 使用一次后立即失效这时候第一个请求刷新成功,后面的请求再使用旧 RefreshToken 刷新,就可能失败,甚至误判为登录失效。
所以需要:
synchronized(lock)保证同一时间只有一个线程进入刷新逻辑。
八、真正的企业级难点:锁不等于避免重复刷新
很多人以为加了锁就结束了。
其实不是。
锁只能解决:
同一时间只有一个线程刷新但是不能解决:
排队进来的线程继续重复刷新举个例子:
A 进入锁,刷新成功,释放锁 B 进入锁,又刷新一次 C 进入锁,又刷新一次 D 进入锁,又刷新一次最终还是刷新了多次。
所以,还需要:
锁内二次检查也就是这段代码:
val latestAccessToken = TokenManager.getAccessToken() if (latestAccessToken.isNotEmpty() && latestAccessToken != oldAccessToken) { Log.d(TAG, "Token 已被其它线程刷新,直接复用") return RefreshResult.Success(accessToken = latestAccessToken) }这段代码的含义是:
当前请求使用的是 oldAccessToken 进入锁以后,再读取一次最新 accessToken 如果最新 accessToken 已经不等于 oldAccessToken 说明别的线程已经刷新成功了 当前线程就不要再请求后端刷新接口 直接复用最新 token可以记住一句话:
锁 = 排队 二次检查 = 跳过最终效果是:
5 个接口同时 401 A 真正 refreshToken B 发现 Token 已经变了,直接复用 C 发现 Token 已经变了,直接复用 D 发现 Token 已经变了,直接复用 E 发现 Token 已经变了,直接复用最终:
5 个 401 ↓ 1 次 refreshToken ↓ 5 次请求重放这才是真正企业级的并发刷新治理。
九、TokenAuthInterceptor:请求拦截 + 响应拦截 + 请求重放
TokenAuthInterceptor是另一个核心类。
它负责:
请求前添加 Authorization 发现没有 Token 时快速失败 响应 401 后刷新 Token 刷新成功后重放原请求 刷新失败后抛出登录失效 网络异常时不清空 Token完整代码如下:
package com.xxx.lib_net.interceptor import android.util.Log import com.xxx.lib_net.auth.RefreshResult import com.xxx.lib_net.auth.TokenRefresher import com.xxx.lib_net.constant.PublicApiWhitelist import com.xxx.lib_net.error.AuthIOException import com.xxx.lib_net.error.ERROR import com.xxx.lib_net.manager.TokenManager import okhttp3.Interceptor import okhttp3.Response /** * Token认证信息拦截器 * * @author mark.wu * @date 2026/3/27 07:25 * * 【主要功能】 * 1. 添加统一的Content-Type请求头 * 2. 添加Token认证头(Bearer Token) * * 【Token认证逻辑】 * - 默认对所有请求添加Authorization头(Bearer Token) * - 公开接口白名单:通过PublicApiWhitelist配置管理 * - Token存储在TokenManager中,由登录接口获取后保存 */ class TokenAuthInterceptor : Interceptor { companion object { private const val TAG = "TokenAuthInterceptor" } override fun intercept(chain: Interceptor.Chain): Response { val originRequest = chain.request() val newBuilder = originRequest.newBuilder() newBuilder.header("Content-Type", "application/json") val host = originRequest.url.host val url = originRequest.url.toString() Log.e(TAG, "host:$host \n url:$url") // ==================== Token认证处理 ==================== val isPublicApi = PublicApiWhitelist.isPublicApi(url) /** * 记录本次请求真正携带出去的 accessToken。 * * 注意: * 这里不能在 401 返回后再重新 getAccessToken() 当 oldToken, * 因为并发场景下,可能别的请求已经刷新过 Token。 * * oldAccessToken 必须是“当前这次请求发出去时使用的 Token”, * 后续 TokenRefresher 才能通过它做锁内二次检查。 */ var requestAccessToken = "" // 检查是否是公开接口(不需要Token) if (!isPublicApi) { // 需要accessToken的接口 val accessToken = TokenManager.getAccessToken() requestAccessToken = accessToken /** * 快速失败:需要accessToken但没有Token,抛出异常 * * 【设计目的】 * - 节省网络资源:不需要发送请求到服务器 * - 快速响应:立即通知用户未登录状态 * - 错误码:使用 ERROR.UNLOGIN(-1001) 区分本地检测和服务器返回的401 * * 【关键】必须包装为IOException,因为OkHttp拦截器只处理IOException类型的异常 * 这样异常才能被上层的Flow异常处理机制正确捕获,而不是导致应用崩溃 */ if (accessToken.isEmpty()) { throw AuthIOException(ERROR.UNLOGIN.code, "用户未登录,请先登录") } newBuilder.header("Authorization", "Bearer $accessToken") } val authRequest = newBuilder.build() val response = chain.proceed(authRequest) // 公开接口直接放行,不处理 401 刷新 if (isPublicApi) { return response } // 非 401,正常返回 if (response.code != 401) { return response } // 401:accessToken 可能过期,关闭旧 response response.close() // 同步刷新 Token val refreshResult = TokenRefresher.refreshTokenSync(requestAccessToken) when (refreshResult) { is RefreshResult.Success -> { val retryRequest = originRequest.newBuilder() .header("Content-Type", "application/json") .header("Authorization", "Bearer ${refreshResult.accessToken}") .build() return chain.proceed(retryRequest) } is RefreshResult.TokenExpired -> { TokenManager.clearToken() throw AuthIOException(ERROR.UNLOGIN.code, refreshResult.message ?: "登录已失效,请重新登录") } is RefreshResult.NetworkError -> { /** * 注意: * 这里不要清空 Token。 * * 因为 NetworkError 代表刷新 Token 时发生了网络异常, * 例如断网、超时、DNS异常、SSL异常等。 * * 这种情况不等于 refreshToken 失效, * 所以不应该把用户直接踢下线。 */ throw AuthIOException(ERROR.NETWORD_ERROR.code, "网络异常,Token刷新失败,请稍后重试") } } } }1. 请求阶段:添加 accessToken
newBuilder.header("Authorization", "Bearer $accessToken")这里使用header(),不是addHeader()。
因为 Authorization 是唯一 Header。
header()的语义是:
如果已有同名 Header,先移除,再设置新的值这比addHeader()更适合 Authorization、Content-Type、tenant-id 这类唯一值 Header。
2. 快速失败:本地没有 Token,不发请求
if (accessToken.isEmpty()) { throw AuthIOException(ERROR.UNLOGIN.code, "用户未登录,请先登录") }如果一个接口需要登录,但是本地根本没有 accessToken,就没必要再请求服务器。
这叫:
快速失败好处是:
节省网络资源 快速通知上层 避免无意义请求3. 响应阶段:处理 401
val response = chain.proceed(authRequest) if (response.code != 401) { return response }这里就是 OkHttp 拦截器的 U 型结构:
proceed() 前:请求拦截 proceed() 后:响应拦截所以 Interceptor 不只是请求拦截,也可以做响应拦截。
4. 为什么要 response.close()?
response.close()因为 401 的旧响应已经不用了。
后面要重新发起请求。
如果不关闭旧 response,可能造成资源泄露。
5. 请求重放
刷新成功后:
val retryRequest = originRequest.newBuilder() .header("Content-Type", "application/json") .header("Authorization", "Bearer ${refreshResult.accessToken}") .build() return chain.proceed(retryRequest)这就是请求重放。
也就是:
原请求失败 ↓ 刷新 Token ↓ 用新 Token 再请求一次原接口注意,这里用的是:
originRequest.newBuilder()因为我们要基于原请求重新构建,只替换 Authorization。
十、NetworkError 为什么不能清 Token?
这是很多项目容易犯错的地方。
刷新 Token 失败,不一定代表登录失效。
例如:
用户坐地铁 接口返回 401 准备 refreshToken 刚好网络断开 refreshToken 请求超时这时候如果直接:
clearToken 跳登录用户体验会很差。
因为用户不是登录失效,只是网络异常。
所以这里要区分:
is RefreshResult.TokenExpired -> { TokenManager.clearToken() throw AuthIOException(ERROR.UNLOGIN.code, ...) } is RefreshResult.NetworkError -> { throw AuthIOException(ERROR.NETWORD_ERROR.code, ...) }也就是:
TokenExpired:清 token,跳登录 NetworkError:不清 token,只提示网络异常这就是RefreshResult的价值。
十一、PublicParameterInterceptor:公共 Header 统一处理
除了 Token 认证外,网络框架通常还需要统一添加公共参数。
例如:
device-type device-os-version tenant-id代码如下:
package com.sqx.lib_net.interceptor import android.util.Log import com.xxx.lib_basic.manager.AppManager import com.xxx.lib_net.manager.HeadManager import okhttp3.Interceptor import okhttp3.Response /** * 公共参数拦截器 * * @author mark.wu * @date 2026/3/27 08:27 */ class PublicParameterInterceptor : Interceptor { companion object{ private const val TAG = "PublicParameterInterceptor" } override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() val newBuilder = request.newBuilder().apply { header("device-type", "android") // header("app-version", AppManager.getAppVersionName(SumAppHelper.getApplication())) // header("device-id", DeviceInfoUtils.androidId) header("device-os-version", AppManager.getDeviceBuildRelease())//获取手机系统版本号 // val deviceNameStr = AppManager.getDeviceBuildBrand().plus("_") // .plus(AppManager.getDeviceBuildModel()) // header("device-name", URLEncoder.encode(deviceNameStr, "UTF-8"))//获取设备类型 //添加租户头(优先从内存获取,如果内存没有再从 MMKV 读取) val tenantId = HeadManager.getTenantId() ?: "no-tenant-id"// 读取 tenantId,不存在则使用默认值 Log.d(TAG, "tenant-id: $tenantId") header("tenant-id", tenantId) } return chain.proceed(newBuilder.build()) } }这里也统一使用:
header()而不是:
addHeader()原因是:
device-type device-os-version tenant-id这些都是唯一值 Header。
如果用addHeader(),语义是追加。
如果以后多个拦截器都添加 tenant-id,可能出现:
tenant-id: 1001 tenant-id: 1001而header()的语义是覆盖,更符合这种固定 Header 的场景。
可以形成一个团队规范:
固定唯一 Header,用 header() 允许多个同名 Header,用 addHeader()例如:
Authorization -> header() Content-Type -> header() tenant-id -> header() device-type -> header() Cookie -> addHeader() Accept -> 视情况而定十二、完整请求流程
现在把整个流程串起来。
正常请求
业务接口 ↓ PublicParameterInterceptor 添加公共 Header ↓ TokenAuthInterceptor 添加 Authorization ↓ 服务端校验通过 ↓ 返回业务数据accessToken 过期
业务接口 ↓ 携带旧 accessToken ↓ 服务端返回 401 ↓ TokenAuthInterceptor 捕获 401 ↓ 关闭旧 response ↓ 调用 TokenRefresher.refreshTokenSync() ↓ RefreshRetrofit 请求刷新接口 ↓ 服务端返回新 TokenInfo ↓ TokenManager 保存新 Token ↓ TokenAuthInterceptor 重放原请求 ↓ 业务接口成功refreshToken 失效
业务接口返回 401 ↓ 尝试刷新 Token ↓ 后端明确拒绝刷新 ↓ RefreshResult.TokenExpired ↓ TokenManager.clearToken() ↓ 抛 AuthIOException(ERROR.UNLOGIN) ↓ 上层跳登录刷新 Token 时网络异常
业务接口返回 401 ↓ 尝试刷新 Token ↓ 网络断开 / 超时 / DNS异常 ↓ RefreshResult.NetworkError ↓ 不清 Token ↓ 抛 AuthIOException(ERROR.NETWORD_ERROR) ↓ 上层提示网络异常多接口同时 401
A 请求 401 B 请求 401 C 请求 401 D 请求 401 E 请求 401 ↓ A 进入锁 A 执行 refreshToken A 保存新 token A 重放请求 ↓ B 进入锁 B 发现 latestToken != oldToken B 直接复用新 token B 重放请求 ↓ C / D / E 同理最终:
5 个接口同时 401 只刷新 1 次 Token 5 个接口都用新 Token 重放十三、这套方案解决了什么问题?
这套双 Token 网络认证架构,解决了以下问题:
1. accessToken 过期后自动续期 2. refreshToken 独立刷新 3. 401 统一处理 4. 请求自动重放 5. Token 状态统一管理 6. 网络异常和登录失效分离 7. 多接口同时 401 时避免重复刷新 8. RefreshToken 一次性场景下避免状态错乱 9. 公共 Header 统一添加 10. 网络框架认证体系职责清晰最终得到的不是一个简单拦截器,而是一套完整的网络认证子系统。
十四、面试怎么讲?
如果面试官问:
你们项目里的 Token 过期怎么处理?不要只回答:
接口返回 401 后刷新 Token。这样太浅。
可以这样回答:
我们项目里采用双 Token 机制。
AccessToken 用于访问业务接口,RefreshToken 用于刷新 AccessToken。
网络层通过 OkHttp Interceptor 统一添加 Authorization。
当业务接口返回 401 时,拦截器会调用 TokenRefresher,通过独立 Retrofit 使用 RefreshToken 同步刷新 Token。
刷新成功后,会保存新的 TokenInfo,并使用新的 AccessToken 重放原请求。
如果 RefreshToken 失效,会清空本地 Token 并抛出未登录异常。
如果刷新过程中发生网络异常,则不会清空 Token,而是抛出网络错误,避免用户只是断网却被误踢下线。
另外,我们对多个接口同时返回 401 的场景做了并发控制。
通过 synchronized 加锁保证同一时间只有一个线程刷新 Token,并且在锁内做二次检查。
如果发现 Token 已经被其他线程刷新过,当前请求就直接复用新 Token 重放请求,不会再次请求刷新接口。
这样可以避免重复刷新,也避免 RefreshToken 一次性失效导致的登录状态错乱。
这套回答就已经是架构级回答了。
十五、总结
很多人以为双 Token 的核心只是:
AccessToken RefreshToken实际上,企业项目里真正难的是:
401 怎么统一处理? 刷新成功后原请求怎么继续? 刷新失败到底是登录失效还是网络异常? 多个接口同时 401 怎么避免重复刷新? RefreshToken 一次性失效怎么办? Token 状态如何统一管理?所以,真正完整的双 Token 方案,至少应该包含:
TokenManager AuthApi RefreshRetrofit TokenRefresher RefreshResult TokenAuthInterceptor PublicParameterInterceptor以及:
请求拦截 响应拦截 Token刷新 请求重放 异常分类 并发控制 锁内二次检查最终可以记住一句话:
AccessToken 负责访问业务接口。 RefreshToken 负责续期。 Interceptor 负责拦截与重放。 TokenRefresher 负责刷新。 RefreshResult 负责状态分类。 synchronized + 二次检查负责并发治理。这才是一套真正能落地到企业项目里的 Android 网络认证架构。