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
+110 -18
View File
@@ -1,10 +1,12 @@
/**
* 接口客户端。
*
* 三条约定:
* 四条约定:
* 1. 后端永远返回 {code,message,data,request_id},code !== 0 一律抛出。
* 2. 写请求自动带 Idempotency-Key,网络抖动重试不会重复建数据。
* 3. 401 统一清理本地令牌并回到登录页,不在业务里各写一遍。
* 3. 访问令牌只有 30 分钟:快到期先用 refresh 换一对新的,撞上 401 也再换一次重放,
* 换不动才清令牌回登录页。以前是 401 直接踢人,用着用着就被扔出去。
* 4. 并发请求共用同一次刷新,别在 401 风暴里打出十个 /auth/refresh。
*/
const BASE = "/api/v1";
@@ -48,6 +50,60 @@ export const session = {
export const authExpired = new EventTarget();
/** 访问令牌的剩余寿命(秒);解不出来就当作"不知道",交给 401 那条路。 */
function accessExpiresIn(token: string | null): number | null {
if (!token) return null;
const payload = token.split(".")[1];
if (!payload) return null;
try {
const padded = payload.replace(/-/g, "+").replace(/_/g, "/");
const json = JSON.parse(atob(padded.padEnd(Math.ceil(padded.length / 4) * 4, "=")));
if (typeof json?.exp !== "number") return null;
return json.exp - Math.floor(Date.now() / 1000);
} catch {
return null;
}
}
let refreshing: Promise<boolean> | null = null;
/**
* 用 refresh token 换一对新令牌。
*
* 返回 false 表示换不动(没令牌、过期、被改过),调用方负责清理会话。
* `/auth/refresh` 的返回是**扁平**的令牌对象,不带 tokens 信封,别照登录那套解析。
*/
export function refreshSession(): Promise<boolean> {
if (refreshing) return refreshing;
const refresh = session.refresh;
if (!refresh) return Promise.resolve(false);
refreshing = (async () => {
try {
const resp = await fetch(`${BASE}/auth/refresh`, {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refresh }),
});
const body = await resp.json().catch(() => null);
if (!resp.ok || !body || body.code !== 0) return false;
const tokens = body.data as Tokens;
if (!tokens?.access_token || !tokens?.refresh_token) return false;
session.save(tokens);
return true;
} catch {
return false;
} finally {
refreshing = null;
}
})();
return refreshing;
}
/** 这些路径自己就是在谈登录,401 不要拿去刷新(否则错密码会被刷新掩盖)。 */
function isAuthPath(path: string): boolean {
return path.startsWith("/auth/login") || path.startsWith("/auth/register") || path.startsWith("/auth/refresh");
}
/**
* 幂等键。
*
@@ -81,8 +137,13 @@ interface RequestOptions {
idempotencyKey?: string;
}
async function raw<T>(path: string, opts: RequestOptions = {}): Promise<T> {
const method: Method = opts.method ?? "GET";
interface Parsed<T> {
ok: boolean;
status: number;
body: { code: number; message: string; data: T; request_id: string } | null;
}
async function send<T>(path: string, opts: RequestOptions, method: Method): Promise<Parsed<T>> {
const headers: Record<string, string> = { Accept: "application/json" };
if (opts.body !== undefined) headers["Content-Type"] = "application/json";
@@ -101,21 +162,43 @@ async function raw<T>(path: string, opts: RequestOptions = {}): Promise<T> {
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
});
let body: { code: number; message: string; data: T; request_id: string } | null = null;
let body: Parsed<T>["body"] = null;
try {
body = await resp.json();
} catch {
body = null;
}
return { ok: resp.ok, status: resp.status, body };
}
if (!body) {
throw new ApiError(resp.status, `服务返回了非 JSON 响应(HTTP ${resp.status})`);
async function raw<T>(path: string, opts: RequestOptions = {}): Promise<T> {
const method: Method = opts.method ?? "GET";
const canRefresh = !isAuthPath(path);
// 提前换:剩不到 90 秒就先续上,别等用户正在打字时吃一个 401
if (canRefresh && session.refresh) {
const left = accessExpiresIn(session.access);
if (left !== null && left < 90) await refreshSession();
}
if (!resp.ok || body.code !== 0) {
if (resp.status === 401) {
let result = await send<T>(path, opts, method);
if (result.status === 401) {
// 令牌可能只是过期了:换一对重放一次,用户的输入不该因为这件事丢掉
if (canRefresh && (await refreshSession())) {
result = await send<T>(path, opts, method);
}
if (result.status === 401) {
session.clear();
authExpired.dispatchEvent(new Event("expired"));
}
}
const { ok, body } = result;
if (!body) {
throw new ApiError(result.status, `服务返回了非 JSON 响应(HTTP ${result.status})`);
}
if (!ok || body.code !== 0) {
throw new ApiError(body.code, body.message, body.request_id);
}
return body.data;
@@ -139,12 +222,25 @@ export async function uploadFile<T>(path: string, file: File): Promise<T> {
const form = new FormData();
form.append("file", file, file.name);
const headers: Record<string, string> = { Accept: "application/json" };
const token = session.access;
if (token) headers.Authorization = `Bearer ${token}`;
headers["Idempotency-Key"] = newIdempotencyKey();
const post = () => {
const headers: Record<string, string> = {
Accept: "application/json",
"Idempotency-Key": newIdempotencyKey(),
};
const token = session.access;
if (token) headers.Authorization = `Bearer ${token}`;
return fetch(`${BASE}${path}`, { method: "POST", headers, body: form });
};
const resp = await fetch(`${BASE}${path}`, { method: "POST", headers, body: form });
let resp = await post();
if (resp.status === 401 && (await refreshSession())) {
// 附件也走同一条续期路线:换完令牌重发一次,不要把半截上传变成"重新登录"
resp = await post();
if (resp.status === 401) {
session.clear();
authExpired.dispatchEvent(new Event("expired"));
}
}
let body: { code: number; message: string; data: T; request_id: string } | null = null;
try {
@@ -156,10 +252,6 @@ export async function uploadFile<T>(path: string, file: File): Promise<T> {
throw new ApiError(resp.status, `上传失败:服务返回了非 JSON 响应(HTTP ${resp.status})`);
}
if (!resp.ok || body.code !== 0) {
if (resp.status === 401) {
session.clear();
authExpired.dispatchEvent(new Event("expired"));
}
throw new ApiError(body.code, body.message, body.request_id);
}
return body.data;