修复泥点消耗刷新不及时 (#138)
Project CI / Repository checks (push) Successful in 50s
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled

refactor:
- 主站复用game agent的zustand store

fix:
- 修复旧请求覆盖问题
- 修复账号切换问题

- 主站原本已经每 4 秒轮询 external generation 任务状态, 在这里补充触发余额刷新的时机

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/138
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
This commit was merged in pull request #138.
This commit is contained in:
2026-08-08 10:25:02 +08:00
committed by 段舒康
parent 44de26d204
commit fc0dcb0782
36 changed files with 4160 additions and 491 deletions
+105 -15
View File
@@ -114,7 +114,9 @@ function normalizeHeaders(headers?: HeadersInit) {
function hasHeader(headers: Record<string, string>, name: string) {
const normalizedName = name.toLowerCase();
return Object.keys(headers).some((key) => key.toLowerCase() === normalizedName);
return Object.keys(headers).some(
(key) => key.toLowerCase() === normalizedName,
);
}
function setHeaderIfMissing(
@@ -146,7 +148,11 @@ function attachHostRuntimeHeaders(headers: Record<string, string>) {
runtime.clientRuntime || 'wechat_mini_program',
);
setHeaderIfMissing(headers, CLIENT_PLATFORM_HEADER, runtime.hostPlatform);
setHeaderIfMissing(headers, MINI_PROGRAM_ENV_HEADER, runtime.miniProgramEnv);
setHeaderIfMissing(
headers,
MINI_PROGRAM_ENV_HEADER,
runtime.miniProgramEnv,
);
return headers;
}
@@ -171,7 +177,10 @@ function buildClientRequestId() {
return `web-${randomId}`;
}
function resolveRequestIdHeader(headers: Record<string, string>, options: ApiRequestOptions) {
function resolveRequestIdHeader(
headers: Record<string, string>,
options: ApiRequestOptions,
) {
const explicitRequestId = options.requestId?.trim();
const existingRequestId = Object.entries(headers).find(
([key, value]) => key.toLowerCase() === REQUEST_ID_HEADER && value.trim(),
@@ -630,7 +639,9 @@ export function getStoredAccessToken() {
return window.localStorage.getItem(ACCESS_TOKEN_KEY)?.trim() || '';
}
export function setStoredAccessToken(
let authStateGeneration = 0;
function writeStoredAccessToken(
token: string,
options: {
emit?: boolean;
@@ -654,6 +665,20 @@ export function setStoredAccessToken(
}
}
export function setStoredAccessToken(
token: string,
options: {
emit?: boolean;
} = {},
) {
if (!canUseLocalStorage()) {
return;
}
authStateGeneration += 1;
writeStoredAccessToken(token, options);
}
export function clearStoredAccessToken(
options: {
emit?: boolean;
@@ -663,6 +688,7 @@ export function clearStoredAccessToken(
return;
}
authStateGeneration += 1;
const previousToken = getStoredAccessToken();
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
@@ -687,7 +713,50 @@ function withAuthorizationHeaders(
return nextHeaders;
}
let refreshAccessTokenPromise: Promise<string> | null = null;
type AuthStateSnapshot = {
generation: number;
accessToken: string;
};
type RefreshAccessTokenAttempt = AuthStateSnapshot & {
promise: Promise<string>;
};
class AuthStateChangedDuringRefreshError extends Error {
constructor() {
super('刷新期间登录状态已变化');
this.name = 'AuthStateChangedDuringRefreshError';
}
}
let refreshAccessTokenAttempt: RefreshAccessTokenAttempt | null = null;
function captureAuthStateSnapshot(): AuthStateSnapshot {
return {
generation: authStateGeneration,
accessToken: getStoredAccessToken(),
};
}
function isCurrentAuthState(snapshot: AuthStateSnapshot) {
return (
authStateGeneration === snapshot.generation &&
getStoredAccessToken() === snapshot.accessToken
);
}
function publishRefreshedAccessToken(
nextToken: string,
snapshot: AuthStateSnapshot,
) {
if (!isCurrentAuthState(snapshot)) {
throw new AuthStateChangedDuringRefreshError();
}
// refresh 只轮换同一账号的 access token,不推进账号代际。
// 外部登录、切号或退出通过公开 setter 推进代际,使旧 refresh 发布失效。
writeStoredAccessToken(nextToken, { emit: false });
}
function shouldClearAuthAfterRefreshFailure(error: unknown) {
return (
@@ -697,11 +766,16 @@ function shouldClearAuthAfterRefreshFailure(error: unknown) {
}
async function refreshAccessToken() {
if (refreshAccessTokenPromise) {
return refreshAccessTokenPromise;
const authStateSnapshot = captureAuthStateSnapshot();
if (
refreshAccessTokenAttempt &&
refreshAccessTokenAttempt.generation === authStateSnapshot.generation &&
refreshAccessTokenAttempt.accessToken === authStateSnapshot.accessToken
) {
return refreshAccessTokenAttempt.promise;
}
refreshAccessTokenPromise = (async () => {
const promise = (async () => {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'same-origin',
@@ -726,14 +800,21 @@ async function refreshAccessToken() {
throw new Error('刷新登录状态失败');
}
setStoredAccessToken(nextToken, { emit: false });
publishRefreshedAccessToken(nextToken, authStateSnapshot);
return nextToken;
})();
const attempt: RefreshAccessTokenAttempt = {
...authStateSnapshot,
promise,
};
refreshAccessTokenAttempt = attempt;
try {
return await refreshAccessTokenPromise;
return await promise;
} finally {
refreshAccessTokenPromise = null;
if (refreshAccessTokenAttempt === attempt) {
refreshAccessTokenAttempt = null;
}
}
}
@@ -752,12 +833,14 @@ export async function refreshStoredAccessToken(
clearOnFailure?: boolean;
} = {},
) {
const authStateSnapshot = captureAuthStateSnapshot();
try {
return await refreshAccessToken();
} catch (error) {
if (
options.clearOnFailure !== false &&
shouldClearAuthAfterRefreshFailure(error)
shouldClearAuthAfterRefreshFailure(error) &&
isCurrentAuthState(authStateSnapshot)
) {
clearStoredAccessToken({ emit: false });
}
@@ -774,7 +857,10 @@ export async function fetchWithApiAuth(
const retry = resolveRetryOptions(method, options.retry);
const authFailurePolicy = resolveAuthFailurePolicy(options);
const requestSignal = init.signal ?? undefined;
const requestId = resolveRequestIdHeader(normalizeHeaders(init.headers), options);
const requestId = resolveRequestIdHeader(
normalizeHeaders(init.headers),
options,
);
let attempt = 0;
let refreshAttempted = false;
@@ -830,6 +916,7 @@ export async function fetchWithApiAuth(
!authFailurePolicy.skipRefresh &&
!refreshAttempted
) {
const refreshAuthStateSnapshot = captureAuthStateSnapshot();
try {
await awaitWithAbortSignal(refreshAccessToken(), requestSignal);
refreshAttempted = true;
@@ -844,7 +931,8 @@ export async function fetchWithApiAuth(
const shouldClearAuth =
hasAuthHeader &&
authFailurePolicy.clearAuthOnUnauthorized &&
shouldClearAuthAfterRefreshFailure(refreshError);
shouldClearAuthAfterRefreshFailure(refreshError) &&
isCurrentAuthState(refreshAuthStateSnapshot);
if (shouldClearAuth) {
clearStoredAccessToken({ emit: false });
}
@@ -897,7 +985,9 @@ async function buildApiClientError(
const baseMessage = parseApiErrorMessage(responseText, fallbackMessage);
return new ApiClientError({
message: requestId ? `${baseMessage}requestId: ${requestId}` : baseMessage,
message: requestId
? `${baseMessage}requestId: ${requestId}`
: baseMessage,
status: response.status,
code: parsedError?.code ?? `HTTP_${response.status || 0}`,
details: parsedError?.details ?? null,