服务端一直签 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:
+8
-1
@@ -56,6 +56,10 @@
|
|||||||
`Authorization: Bearer <access_token>`。`access` 30 分钟,`refresh` 14 天。
|
`Authorization: Bearer <access_token>`。`access` 30 分钟,`refresh` 14 天。
|
||||||
`refresh_token` 只能用于 `/auth/refresh`(服务端校验 `typ`)。
|
`refresh_token` 只能用于 `/auth/refresh`(服务端校验 `typ`)。
|
||||||
|
|
||||||
|
**客户端要自己续期,别等用户被踢出去**:access 剩不到 90 秒就先换一对,
|
||||||
|
撞上 `401/2002` 也先用 refresh 换一对、把原请求重放一次,换不动才清令牌回登录页。
|
||||||
|
每次 `/auth/refresh` 都会返回新的 refresh,所以只要 14 天内用过一次,登录态就一直往后顺延。
|
||||||
|
|
||||||
### 0.4 幂等
|
### 0.4 幂等
|
||||||
|
|
||||||
所有 `POST/PATCH/DELETE` 都可带 `Idempotency-Key: <uuid>`。
|
所有 `POST/PATCH/DELETE` 都可带 `Idempotency-Key: <uuid>`。
|
||||||
@@ -146,7 +150,10 @@
|
|||||||
{ "refresh_token": "…" }
|
{ "refresh_token": "…" }
|
||||||
```
|
```
|
||||||
|
|
||||||
返回同形状的 `tokens`。无状态 JWT,**服务端不维护黑名单**——token 泄露只能等过期,这是 M0 已知取舍。
|
返回同形状的 `tokens`(**扁平**,没有 `tokens` 信封)。无状态 JWT,**服务端不维护黑名单**——
|
||||||
|
token 泄露只能等过期,这是 M0 已知取舍;换来的是刷新不需要查库。
|
||||||
|
|
||||||
|
换的时候会看一眼账号状态:账号被停用 → `2012`(403),停用的人不能靠 refresh 一直续下去。
|
||||||
|
|
||||||
### `POST /auth/login/password`
|
### `POST /auth/login/password`
|
||||||
|
|
||||||
|
|||||||
@@ -712,3 +712,30 @@
|
|||||||
- `web/e2e/knowledge-read.mjs` 直接打生产(106 的手机号 + 密码登录)8 项全绿,
|
- `web/e2e/knowledge-read.mjs` 直接打生产(106 的手机号 + 密码登录)8 项全绿,
|
||||||
截图 `2.Design/screens/prod-knowledge-read-page.png`。
|
截图 `2.Design/screens/prod-knowledge-read-page.png`。
|
||||||
- 脚本都换成了密码登录(`PASSWORD` env 优先),生产短信登录关着,别再指望发验证码。
|
- 脚本都换成了密码登录(`PASSWORD` env 优先),生产短信登录关着,别再指望发验证码。
|
||||||
|
|
||||||
|
## 12. 登录态续期(2026-11-03)
|
||||||
|
|
||||||
|
用户反馈:"web端登陆后好像没多久就退出登陆了,现在系统对登陆时限是怎么设计的?"
|
||||||
|
|
||||||
|
### 12.1 原来的设计(也就是问题所在)
|
||||||
|
|
||||||
|
- access 30 分钟、refresh 14 天,服务端两个都签好、都发给了客户端。
|
||||||
|
- 但**没有任何客户端用过 refresh**:web 与安卓拿到 `401/2002` 都是直接清令牌、回登录页。
|
||||||
|
- 结果"14 天有效"只是纸面参数:真实登录寿命就是 30 分钟,到点下一次请求就被扔出去,
|
||||||
|
正在写的工单、正在编辑的知识都不会留下。
|
||||||
|
|
||||||
|
### 12.2 改法
|
||||||
|
|
||||||
|
- `web/src/lib/client.ts`:新增 `refreshSession()`(并发只发一次),请求前若 access 剩不到 90 秒先换;
|
||||||
|
收到 401 先换再重放一次原请求,换不动才清令牌发 `authExpired`。`uploadFile` 走同一条路。
|
||||||
|
- `android/app/src/main/java/com/dadao/task/data/Api.kt`:`call()` 拆成 `call` + `callOnce`,
|
||||||
|
2002 时 `refreshTokens(stale)` 用 `Mutex` 串行刷新(等锁期间别人换好了就直接用新的),重放一次。
|
||||||
|
- 后端 `/auth/refresh` 顺手补一条:账号被停用 → `2012`,别让停用账号靠 refresh 一直续。
|
||||||
|
|
||||||
|
### 12.3 验证
|
||||||
|
|
||||||
|
- `backend/tests/test_auth.py::test_disabled_account_cannot_refresh`(停用账号换令牌 → 403/2012),
|
||||||
|
全量 **140 passed**、`ruff check` / `format --check` 全绿。
|
||||||
|
- 新增 `web/e2e/session-refresh.mjs`(6 项):access 换成假串但 refresh 是真的 → 不被踢、首页正常、
|
||||||
|
拿到新令牌、新令牌能调接口;access 与 refresh 都假 → 才回登录页;控制台零异常。
|
||||||
|
- 安卓 `assembleDebug` 通过(JDK 17 + Gradle 8.9)。
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package com.dadao.task.data
|
|||||||
|
|
||||||
import com.dadao.task.BuildConfig
|
import com.dadao.task.BuildConfig
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
@@ -789,11 +791,55 @@ class Api(private val tokens: TokenStore) {
|
|||||||
method = "PATCH",
|
method = "PATCH",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 访问令牌只有 30 分钟,refresh 有 14 天。
|
||||||
|
*
|
||||||
|
* 撞上 2002(登录已过期)时先用 refresh 换一对新令牌,把这次请求原样重放一次;
|
||||||
|
* 换不动才清令牌让界面回登录页。以前是直接清,用户用一会儿就被踢出去。
|
||||||
|
* 并发请求共用一把锁:刷新只发一次,其余等它换完直接用新令牌。
|
||||||
|
*/
|
||||||
private suspend inline fun <reified T> call(
|
private suspend inline fun <reified T> call(
|
||||||
path: String,
|
path: String,
|
||||||
body: String? = null,
|
body: String? = null,
|
||||||
auth: Boolean = false,
|
auth: Boolean = false,
|
||||||
method: String? = null,
|
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) {
|
): T = withContext(Dispatchers.IO) {
|
||||||
val builder = Request.Builder().url(base + path)
|
val builder = Request.Builder().url(base + path)
|
||||||
val verb = method ?: if (body == null) "GET" else "POST"
|
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})")
|
throw ApiException(1500, "响应格式异常(HTTP ${response.code})")
|
||||||
}
|
}
|
||||||
if (envelope.code != 0) {
|
if (envelope.code != 0) {
|
||||||
if (envelope.code == 2002 && auth) tokens.clear()
|
|
||||||
throw ApiException(envelope.code, envelope.message, envelope.requestId)
|
throw ApiException(envelope.code, envelope.message, envelope.requestId)
|
||||||
}
|
}
|
||||||
envelope.data ?: throw ApiException(1500, "响应缺少 data")
|
envelope.data ?: throw ApiException(1500, "响应缺少 data")
|
||||||
|
|||||||
@@ -240,8 +240,14 @@ async def login_password(body: PasswordLoginIn, session: SessionDep) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/refresh", summary="刷新访问令牌")
|
@router.post("/refresh", summary="刷新访问令牌")
|
||||||
async def refresh(body: RefreshIn) -> dict:
|
async def refresh(body: RefreshIn, session: SessionDep) -> dict:
|
||||||
user_id = decode_token(body.refresh_token, "refresh")
|
user_id = decode_token(body.refresh_token, "refresh")
|
||||||
|
# 换令牌时也看一眼账号状态:停用的账号不该靠 refresh 一直续下去
|
||||||
|
user = await session.get(AppUser, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise AppError(ErrorCode.UNAUTHORIZED, "账号不存在")
|
||||||
|
if user.status != "active":
|
||||||
|
raise AppError(ErrorCode.ACCOUNT_DISABLED, "账号已停用,请联系管理员")
|
||||||
tokens = create_token_pair(user_id)
|
tokens = create_token_pair(user_id)
|
||||||
# 这里的形状保持扁平(不带 tokens 信封),老客户端与安卓端都按这个取
|
# 这里的形状保持扁平(不带 tokens 信封),老客户端与安卓端都按这个取
|
||||||
return ok(_tokens_out(tokens))
|
return ok(_tokens_out(tokens))
|
||||||
|
|||||||
@@ -70,6 +70,22 @@ async def test_refresh_returns_working_token_pair(client: AsyncClient) -> None:
|
|||||||
assert me.status_code == 200
|
assert me.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
async def test_disabled_account_cannot_refresh(client: AsyncClient) -> None:
|
||||||
|
# 停用的账号不能靠 refresh 一直续下去:令牌在有效期内也要挡住
|
||||||
|
user = await login(client, "13800000009")
|
||||||
|
from app.db import SessionFactory
|
||||||
|
from app.models import AppUser
|
||||||
|
|
||||||
|
async with SessionFactory() as session, session.begin():
|
||||||
|
row = await session.get(AppUser, user["user_id"])
|
||||||
|
assert row is not None
|
||||||
|
row.status = "disabled"
|
||||||
|
|
||||||
|
resp = await client.post("/api/v1/auth/refresh", json={"refresh_token": user["refresh_token"]})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
assert resp.json()["code"] == 2012
|
||||||
|
|
||||||
|
|
||||||
async def test_requires_token(client: AsyncClient) -> None:
|
async def test_requires_token(client: AsyncClient) -> None:
|
||||||
resp = await client.get("/api/v1/auth/me")
|
resp = await client.get("/api/v1/auth/me")
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* 登录态续期的验收:访问令牌过期时,界面不该把人踢回登录页。
|
||||||
|
*
|
||||||
|
* 背景:access 只有 30 分钟,refresh 有 14 天,但以前前端拿到 401 就直接清令牌跳登录页,
|
||||||
|
* refresh 存着没人用——用户的感觉就是"用一会儿就被踢出去"。
|
||||||
|
* 现在前端会先用 refresh 换一对令牌、把原请求重放一次,换不动才清。
|
||||||
|
*
|
||||||
|
* 这里不能造一个"真的过期"的签名令牌,所以把 access 换成一串假的:
|
||||||
|
* 服务端照样回 401,走的还是同一条续期分支。
|
||||||
|
*
|
||||||
|
* 用法:node web/e2e/session-refresh.mjs
|
||||||
|
*/
|
||||||
|
import { chromium } from "playwright-core";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const WEB = process.env.WEB ?? "http://127.0.0.1:5180";
|
||||||
|
const API = process.env.API ?? "http://127.0.0.1:8100/api/v1";
|
||||||
|
const PHONE = process.env.PHONE ?? "13900000001";
|
||||||
|
const CHROME =
|
||||||
|
process.env.CHROME ??
|
||||||
|
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`;
|
||||||
|
const PWD_FILE = new URL("../../.local/e2e-account-password.txt", import.meta.url);
|
||||||
|
|
||||||
|
let pass = 0;
|
||||||
|
let fail = 0;
|
||||||
|
function check(name, ok, extra = "") {
|
||||||
|
if (ok) {
|
||||||
|
pass += 1;
|
||||||
|
console.log(`PASS ${name}`);
|
||||||
|
} else {
|
||||||
|
fail += 1;
|
||||||
|
console.log(`FAIL ${name}${extra ? ` — ${extra}` : ""}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = (() => {
|
||||||
|
if (process.env.PASSWORD) return process.env.PASSWORD;
|
||||||
|
try {
|
||||||
|
return readFileSync(PWD_FILE, "utf8").trim();
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
if (!password) throw new Error("没有密码:设 PASSWORD,或先跑一遍 account.mjs 生成 .local/e2e-account-password.txt");
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
const r = await fetch(`${API}/auth/login/password`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ phone: PHONE, password }),
|
||||||
|
});
|
||||||
|
const body = await r.json();
|
||||||
|
if (body.code !== 0) throw new Error(`登录失败:${body.code} ${body.message}`);
|
||||||
|
return body.data.tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await login();
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ executablePath: CHROME });
|
||||||
|
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
const errors = [];
|
||||||
|
page.on("pageerror", (e) => errors.push(String(e)));
|
||||||
|
|
||||||
|
// ① 假 access + 真 refresh:应该自己续上,留在首页
|
||||||
|
await page.addInitScript(
|
||||||
|
([access, refresh]) => {
|
||||||
|
localStorage.setItem("dadao.access", access);
|
||||||
|
localStorage.setItem("dadao.refresh", refresh);
|
||||||
|
},
|
||||||
|
["bogus.access.token", tokens.refresh_token],
|
||||||
|
);
|
||||||
|
await page.goto(`${WEB}/`, { waitUntil: "networkidle" });
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
|
||||||
|
check("令牌过期也不会被踢回登录页", !page.url().includes("/login"), page.url());
|
||||||
|
check(
|
||||||
|
"首页照常渲染(能看到工作区列表或新建入口)",
|
||||||
|
(await page.getByText(/我的工作区|新建工作区/).first().isVisible().catch(() => false)) === true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const afterRefresh = await page.evaluate(() => localStorage.getItem("dadao.access"));
|
||||||
|
check("拿到的是一对新的访问令牌", Boolean(afterRefresh) && afterRefresh !== "bogus.access.token");
|
||||||
|
|
||||||
|
// 新令牌要真能用:再请求一次业务接口
|
||||||
|
const probe = await page.evaluate(async () => {
|
||||||
|
const token = localStorage.getItem("dadao.access");
|
||||||
|
const r = await fetch("/api/v1/auth/me", { headers: { Authorization: `Bearer ${token}` } });
|
||||||
|
return r.status;
|
||||||
|
});
|
||||||
|
check("新令牌能直接调业务接口", probe === 200, `HTTP ${probe}`);
|
||||||
|
|
||||||
|
// ② access 与 refresh 都是假的:这次才该回登录页
|
||||||
|
const page2 = await context.newPage();
|
||||||
|
await page2.addInitScript(() => {
|
||||||
|
localStorage.setItem("dadao.access", "bogus.access.token");
|
||||||
|
localStorage.setItem("dadao.refresh", "bogus.refresh.token");
|
||||||
|
});
|
||||||
|
await page2.goto(`${WEB}/`, { waitUntil: "networkidle" });
|
||||||
|
await page2.waitForTimeout(800);
|
||||||
|
check(
|
||||||
|
"连 refresh 都失效时,才回到登录页",
|
||||||
|
page2.url().includes("/login") ||
|
||||||
|
(await page2.getByText(/手机号|邮箱/).first().isVisible().catch(() => false)) === true,
|
||||||
|
page2.url(),
|
||||||
|
);
|
||||||
|
|
||||||
|
check("控制台没有未捕获异常", errors.length === 0, errors.join(" | "));
|
||||||
|
|
||||||
|
await page.screenshot({ path: "e2e/artifacts/session-refresh.png" });
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
console.log(fail === 0 ? "ALL PASS" : `${fail} FAILED`);
|
||||||
|
process.exit(fail === 0 ? 0 : 1);
|
||||||
+109
-17
@@ -1,10 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* 接口客户端。
|
* 接口客户端。
|
||||||
*
|
*
|
||||||
* 三条约定:
|
* 四条约定:
|
||||||
* 1. 后端永远返回 {code,message,data,request_id},code !== 0 一律抛出。
|
* 1. 后端永远返回 {code,message,data,request_id},code !== 0 一律抛出。
|
||||||
* 2. 写请求自动带 Idempotency-Key,网络抖动重试不会重复建数据。
|
* 2. 写请求自动带 Idempotency-Key,网络抖动重试不会重复建数据。
|
||||||
* 3. 401 统一清理本地令牌并回到登录页,不在业务里各写一遍。
|
* 3. 访问令牌只有 30 分钟:快到期先用 refresh 换一对新的,撞上 401 也再换一次重放,
|
||||||
|
* 换不动才清令牌回登录页。以前是 401 直接踢人,用着用着就被扔出去。
|
||||||
|
* 4. 并发请求共用同一次刷新,别在 401 风暴里打出十个 /auth/refresh。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const BASE = "/api/v1";
|
const BASE = "/api/v1";
|
||||||
@@ -48,6 +50,60 @@ export const session = {
|
|||||||
|
|
||||||
export const authExpired = new EventTarget();
|
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;
|
idempotencyKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function raw<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
interface Parsed<T> {
|
||||||
const method: Method = opts.method ?? "GET";
|
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" };
|
const headers: Record<string, string> = { Accept: "application/json" };
|
||||||
if (opts.body !== undefined) headers["Content-Type"] = "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),
|
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 {
|
try {
|
||||||
body = await resp.json();
|
body = await resp.json();
|
||||||
} catch {
|
} catch {
|
||||||
body = null;
|
body = null;
|
||||||
}
|
}
|
||||||
|
return { ok: resp.ok, status: resp.status, body };
|
||||||
if (!body) {
|
|
||||||
throw new ApiError(resp.status, `服务返回了非 JSON 响应(HTTP ${resp.status})`);
|
|
||||||
}
|
}
|
||||||
if (!resp.ok || body.code !== 0) {
|
|
||||||
if (resp.status === 401) {
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
session.clear();
|
||||||
authExpired.dispatchEvent(new Event("expired"));
|
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);
|
throw new ApiError(body.code, body.message, body.request_id);
|
||||||
}
|
}
|
||||||
return body.data;
|
return body.data;
|
||||||
@@ -139,12 +222,25 @@ export async function uploadFile<T>(path: string, file: File): Promise<T> {
|
|||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("file", file, file.name);
|
form.append("file", file, file.name);
|
||||||
|
|
||||||
const headers: Record<string, string> = { Accept: "application/json" };
|
const post = () => {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Idempotency-Key": newIdempotencyKey(),
|
||||||
|
};
|
||||||
const token = session.access;
|
const token = session.access;
|
||||||
if (token) headers.Authorization = `Bearer ${token}`;
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
headers["Idempotency-Key"] = newIdempotencyKey();
|
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;
|
let body: { code: number; message: string; data: T; request_id: string } | null = null;
|
||||||
try {
|
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})`);
|
throw new ApiError(resp.status, `上传失败:服务返回了非 JSON 响应(HTTP ${resp.status})`);
|
||||||
}
|
}
|
||||||
if (!resp.ok || body.code !== 0) {
|
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);
|
throw new ApiError(body.code, body.message, body.request_id);
|
||||||
}
|
}
|
||||||
return body.data;
|
return body.data;
|
||||||
|
|||||||
Reference in New Issue
Block a user