fix(auth): 令牌到期自动续上,不再用半小时就被踢回登录页
CI / backend (push) Canceled after 0s

服务端一直签 access(30 分钟)和 refresh(14 天)两个令牌,但两个客户端拿到 401 都是直接清
令牌跳登录页,refresh 从来没被用过——"14 天有效"只是纸面参数,真实寿命就是 30 分钟。

- web/client.ts:请求前剩不到 90 秒先换;撞 401 先换再重放一次原请求;并发共用一次刷新;
  上传附件同一条路
- android/Api.kt:call 拆 call/callOnce,2002 时用 Mutex 串行刷新后重放
- 后端 /auth/refresh 看一眼账号状态:停用的账号不能靠 refresh 一直续(2012)
- 新增 session-refresh.mjs:假 access + 真 refresh 要能留住人,两个都假才回登录页
This commit is contained in:
Steven
2026-11-03 22:53:43 +08:00
parent 02ef47e753
commit 2c338490e5
7 changed files with 329 additions and 21 deletions
@@ -2,6 +2,8 @@ package com.dadao.task.data
import com.dadao.task.BuildConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
@@ -789,11 +791,55 @@ class Api(private val tokens: TokenStore) {
method = "PATCH",
)
/**
* 访问令牌只有 30 分钟,refresh 有 14 天。
*
* 撞上 2002(登录已过期)时先用 refresh 换一对新令牌,把这次请求原样重放一次;
* 换不动才清令牌让界面回登录页。以前是直接清,用户用一会儿就被踢出去。
* 并发请求共用一把锁:刷新只发一次,其余等它换完直接用新令牌。
*/
private suspend inline fun <reified T> call(
path: String,
body: String? = null,
auth: Boolean = false,
method: String? = null,
): T {
val stale = tokens.accessToken
return try {
callOnce<T>(path, body, auth, method)
} catch (e: ApiException) {
if (!auth || e.code != 2002 || !refreshTokens(stale)) throw e
callOnce<T>(path, body, auth, method)
}
}
private val refreshLock = Mutex()
private suspend fun refreshTokens(stale: String?): Boolean {
val refresh = tokens.refreshToken ?: return false
return refreshLock.withLock {
// 等锁期间别人可能已经换完了:本地令牌已经不是失败的那一个,直接用新的
val current = tokens.accessToken
if (current != null && current != stale) return@withLock true
try {
val fresh: Tokens = call(
path = "/auth/refresh",
body = json.encodeToString(RefreshRequest(refresh)),
)
tokens.save(fresh.accessToken, fresh.refreshToken)
true
} catch (e: ApiException) {
tokens.clear()
false
}
}
}
private suspend inline fun <reified T> callOnce(
path: String,
body: String? = null,
auth: Boolean = false,
method: String? = null,
): T = withContext(Dispatchers.IO) {
val builder = Request.Builder().url(base + path)
val verb = method ?: if (body == null) "GET" else "POST"
@@ -817,7 +863,6 @@ class Api(private val tokens: TokenStore) {
throw ApiException(1500, "响应格式异常(HTTP ${response.code})")
}
if (envelope.code != 0) {
if (envelope.code == 2002 && auth) tokens.clear()
throw ApiException(envelope.code, envelope.message, envelope.requestId)
}
envelope.data ?: throw ApiException(1500, "响应缺少 data")