Files
Genarrative/src/services/apiClient.ts
T
kdletters c022bbb8a0 修复小程序内充值设备识别
为 API 请求和刷新请求补充宿主运行时请求头。

在小程序充值设备身份异常时清理旧 token 并重新拉起小程序登录。

补充小程序充值回归测试并隔离测试 spy。
2026-07-09 16:28:32 +08:00

812 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { AuthRefreshResponse } from '../../packages/shared/src/contracts/auth';
import {
API_RESPONSE_ENVELOPE_HEADER,
API_RESPONSE_ENVELOPE_VERSION,
API_VERSION,
type ApiErrorPayload,
type ApiMeta,
parseApiErrorMessage,
unwrapApiResponse,
} from '../../packages/shared/src/http';
import { getHostRuntime } from './host-bridge/hostBridge';
const ACCESS_TOKEN_KEY = 'genarrative.auth.access-token.v1';
export const AUTH_STATE_EVENT = 'genarrative-auth-state-changed';
const REQUEST_ID_HEADER = 'x-request-id';
const API_VERSION_HEADER = 'x-api-version';
const ROUTE_VERSION_HEADER = 'x-route-version';
const CLIENT_TYPE_HEADER = 'x-client-type';
const CLIENT_RUNTIME_HEADER = 'x-client-runtime';
const CLIENT_PLATFORM_HEADER = 'x-client-platform';
const MINI_PROGRAM_ENV_HEADER = 'x-mini-program-env';
const DEFAULT_RETRYABLE_STATUS_CODES = [408, 425, 429, 502, 503, 504];
const DEFAULT_SAFE_RETRY_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
export type ApiRetryOptions = {
maxRetries?: number;
baseDelayMs?: number;
maxDelayMs?: number;
retryableStatusCodes?: number[];
retryUnsafeMethods?: boolean;
allowRetryMethods?: string[];
};
export type ApiAuthImpact = 'global' | 'local';
export type ApiRequestOptions = {
retry?: ApiRetryOptions;
timeoutMs?: number;
skipAuth?: boolean;
omitEnvelopeHeader?: boolean;
skipRefresh?: boolean;
// global:请求失败可影响整站登录态;local:失败只属于当前卡片、图片或运行态。
authImpact?: ApiAuthImpact;
// 会话探测类请求需要静默处理 401,避免 AuthGate 因自发广播再次触发 hydrate。
notifyAuthStateChange?: boolean;
// 推荐页自动加载作品这类局部后台请求失败时,只应让当前卡片报错,不应清空全局登录态。
clearAuthOnUnauthorized?: boolean;
// 同一次业务请求在客户端重试时复用 request id,后端据此做计费幂等。
requestId?: string;
};
export const BACKGROUND_AUTH_REQUEST_OPTIONS = {
authImpact: 'local',
skipRefresh: true,
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
} satisfies ApiRequestOptions;
type ResolvedRetryOptions = {
maxRetries: number;
baseDelayMs: number;
maxDelayMs: number;
retryableStatusCodes: Set<number>;
retryUnsafeMethods: boolean;
allowRetryMethods: Set<string>;
method: string;
};
type ParsedApiErrorShape = {
code: string;
details: Record<string, unknown> | null;
meta: Partial<ApiMeta>;
};
type ResolvedAuthFailurePolicy = {
skipRefresh: boolean;
notifyAuthStateChange: boolean;
clearAuthOnUnauthorized: boolean;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function normalizeHeaders(headers?: HeadersInit) {
const nextHeaders: Record<string, string> = {};
if (typeof Headers !== 'undefined' && headers instanceof Headers) {
headers.forEach((value, key) => {
nextHeaders[key] = value;
});
return nextHeaders;
}
if (Array.isArray(headers)) {
for (const [key, value] of headers) {
nextHeaders[key] = value;
}
return nextHeaders;
}
if (headers) {
Object.assign(nextHeaders, headers);
}
return nextHeaders;
}
function hasHeader(headers: Record<string, string>, name: string) {
const normalizedName = name.toLowerCase();
return Object.keys(headers).some((key) => key.toLowerCase() === normalizedName);
}
function setHeaderIfMissing(
headers: Record<string, string>,
name: string,
value: string | null | undefined,
) {
const trimmedValue = value?.trim();
if (!trimmedValue || hasHeader(headers, name)) {
return;
}
headers[name] = trimmedValue;
}
function attachHostRuntimeHeaders(headers: Record<string, string>) {
let runtime: ReturnType<typeof getHostRuntime>;
try {
runtime = getHostRuntime();
} catch {
return headers;
}
if (runtime.kind === 'wechat_mini_program') {
setHeaderIfMissing(headers, CLIENT_TYPE_HEADER, 'mini_program');
setHeaderIfMissing(
headers,
CLIENT_RUNTIME_HEADER,
runtime.clientRuntime || 'wechat_mini_program',
);
setHeaderIfMissing(headers, CLIENT_PLATFORM_HEADER, runtime.hostPlatform);
setHeaderIfMissing(headers, MINI_PROGRAM_ENV_HEADER, runtime.miniProgramEnv);
return headers;
}
if (runtime.kind === 'native_app') {
setHeaderIfMissing(headers, CLIENT_TYPE_HEADER, 'native_app');
setHeaderIfMissing(
headers,
CLIENT_RUNTIME_HEADER,
runtime.clientRuntime || 'native_app',
);
setHeaderIfMissing(headers, CLIENT_PLATFORM_HEADER, runtime.hostPlatform);
}
return headers;
}
function buildClientRequestId() {
const randomId =
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
return `web-${randomId}`;
}
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(),
)?.[1];
return explicitRequestId || existingRequestId || buildClientRequestId();
}
function coerceMeta(value: unknown): Partial<ApiMeta> {
if (!isRecord(value)) {
return {};
}
return {
apiVersion:
typeof value.apiVersion === 'string' && value.apiVersion.trim()
? value.apiVersion.trim()
: undefined,
requestId:
typeof value.requestId === 'string' && value.requestId.trim()
? value.requestId.trim()
: undefined,
routeVersion:
typeof value.routeVersion === 'string' && value.routeVersion.trim()
? value.routeVersion.trim()
: undefined,
operation:
typeof value.operation === 'string' && value.operation.trim()
? value.operation.trim()
: value.operation === null
? null
: undefined,
latencyMs:
typeof value.latencyMs === 'number' && Number.isFinite(value.latencyMs)
? value.latencyMs
: undefined,
timestamp:
typeof value.timestamp === 'string' && value.timestamp.trim()
? value.timestamp.trim()
: undefined,
};
}
function parseApiErrorShape(rawText: string): ParsedApiErrorShape | null {
if (!rawText.trim()) {
return null;
}
try {
const parsed = JSON.parse(rawText) as
| {
error?: ApiErrorPayload;
meta?: Partial<ApiMeta>;
code?: string;
details?: Record<string, unknown> | null;
}
| Record<string, unknown>;
if (isRecord(parsed.error)) {
return {
code:
typeof parsed.error.code === 'string' && parsed.error.code.trim()
? parsed.error.code.trim()
: 'HTTP_ERROR',
details:
isRecord(parsed.error.details) || parsed.error.details === null
? (parsed.error.details as Record<string, unknown> | null)
: null,
meta: coerceMeta(parsed.meta),
};
}
if (typeof parsed.code === 'string' && parsed.code.trim()) {
return {
code: parsed.code.trim(),
details:
isRecord(parsed.details) || parsed.details === null
? (parsed.details as Record<string, unknown> | null)
: null,
meta: coerceMeta(parsed.meta),
};
}
} catch {
// Ignore malformed json responses.
}
return null;
}
function createAbortError() {
if (typeof DOMException !== 'undefined') {
return new DOMException('The operation was aborted.', 'AbortError');
}
const error = new Error('The operation was aborted.');
error.name = 'AbortError';
return error;
}
function createTimeoutError(timeoutMs: number) {
const error = new Error(`请求超时:${timeoutMs}ms`);
error.name = 'TimeoutError';
return error;
}
function composeAbortSignal(
signal: AbortSignal | undefined,
timeoutMs: number | undefined,
) {
const shouldUseTimeout =
typeof timeoutMs === 'number' &&
Number.isFinite(timeoutMs) &&
timeoutMs > 0;
if (!shouldUseTimeout) {
return {
signal,
cleanup: () => {},
};
}
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort(createTimeoutError(timeoutMs));
}, timeoutMs);
const cleanup = () => {
clearTimeout(timeoutId);
signal?.removeEventListener('abort', onAbort);
};
const onAbort = () => {
controller.abort(signal?.reason ?? createAbortError());
};
if (signal?.aborted) {
cleanup();
controller.abort(signal.reason ?? createAbortError());
return {
signal: controller.signal,
cleanup,
};
}
signal?.addEventListener('abort', onAbort, { once: true });
return {
signal: controller.signal,
cleanup,
};
}
async function waitForRetry(ms: number, signal?: AbortSignal) {
if (ms <= 0) {
return;
}
await new Promise<void>((resolve, reject) => {
const timeoutId = setTimeout(() => {
cleanup();
resolve();
}, ms);
const onAbort = () => {
cleanup();
reject(signal?.reason ?? createAbortError());
};
const cleanup = () => {
clearTimeout(timeoutId);
signal?.removeEventListener('abort', onAbort);
};
if (signal?.aborted) {
cleanup();
reject(signal.reason ?? createAbortError());
return;
}
signal?.addEventListener('abort', onAbort, { once: true });
});
}
function resolveRetryOptions(
method: string,
retry?: ApiRetryOptions,
): ResolvedRetryOptions {
const normalizedMethod = method.toUpperCase();
const defaultMaxRetries = DEFAULT_SAFE_RETRY_METHODS.has(normalizedMethod)
? 1
: 0;
return {
maxRetries:
typeof retry?.maxRetries === 'number' && retry.maxRetries >= 0
? Math.floor(retry.maxRetries)
: defaultMaxRetries,
baseDelayMs:
typeof retry?.baseDelayMs === 'number' && retry.baseDelayMs > 0
? retry.baseDelayMs
: 250,
maxDelayMs:
typeof retry?.maxDelayMs === 'number' && retry.maxDelayMs > 0
? retry.maxDelayMs
: 1500,
retryableStatusCodes: new Set(
retry?.retryableStatusCodes?.length
? retry.retryableStatusCodes
: DEFAULT_RETRYABLE_STATUS_CODES,
),
retryUnsafeMethods: retry?.retryUnsafeMethods === true,
allowRetryMethods: new Set(
(retry?.allowRetryMethods ?? []).map((value) => value.toUpperCase()),
),
method: normalizedMethod,
};
}
function shouldRetryResponse(
status: number,
attempt: number,
retry: ResolvedRetryOptions,
) {
if (attempt >= retry.maxRetries) {
return false;
}
if (!retry.retryableStatusCodes.has(status)) {
return false;
}
return (
retry.retryUnsafeMethods ||
DEFAULT_SAFE_RETRY_METHODS.has(retry.method) ||
retry.allowRetryMethods.has(retry.method)
);
}
export function isAbortError(error: unknown) {
return (
error instanceof Error &&
(error.name === 'AbortError' ||
(typeof DOMException !== 'undefined' &&
error instanceof DOMException &&
error.name === 'AbortError'))
);
}
export function isTimeoutError(error: unknown) {
return error instanceof Error && error.name === 'TimeoutError';
}
function shouldRetryError(
error: unknown,
attempt: number,
retry: ResolvedRetryOptions,
) {
if (attempt >= retry.maxRetries || isAbortError(error)) {
return false;
}
return error instanceof TypeError;
}
function buildRetryDelayMs(attempt: number, retry: ResolvedRetryOptions) {
return Math.min(retry.maxDelayMs, retry.baseDelayMs * Math.max(1, attempt));
}
function resolveAuthFailurePolicy(
options: ApiRequestOptions,
): ResolvedAuthFailurePolicy {
const isLocalAuthImpact = options.authImpact === 'local';
return {
// 局部后台请求可以携带已有 token,但不能主动 refresh
// 否则 refresh 失败会把一次卡片/图片/运行态失败放大成全局掉线。
skipRefresh: isLocalAuthImpact || options.skipRefresh === true,
notifyAuthStateChange: isLocalAuthImpact
? false
: options.notifyAuthStateChange !== false,
clearAuthOnUnauthorized: isLocalAuthImpact
? false
: options.clearAuthOnUnauthorized !== false,
};
}
export class ApiClientError extends Error {
status: number;
code: string;
details: Record<string, unknown> | null;
meta: ApiMeta;
responseText: string;
constructor(params: {
message: string;
status: number;
code: string;
details?: Record<string, unknown> | null;
meta?: Partial<ApiMeta>;
responseText?: string;
}) {
super(params.message);
this.name = 'ApiClientError';
this.status = params.status;
this.code = params.code;
this.details = params.details ?? null;
this.meta = {
apiVersion: params.meta?.apiVersion ?? API_VERSION,
requestId: params.meta?.requestId,
routeVersion: params.meta?.routeVersion,
operation: params.meta?.operation,
latencyMs: params.meta?.latencyMs,
timestamp: params.meta?.timestamp,
};
this.responseText = params.responseText ?? '';
}
}
function canUseLocalStorage() {
return (
typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
);
}
export function emitAuthStateChange() {
if (typeof window === 'undefined') {
return;
}
if (typeof CustomEvent === 'function') {
window.dispatchEvent(new CustomEvent(AUTH_STATE_EVENT));
return;
}
if (typeof Event === 'function') {
window.dispatchEvent(new Event(AUTH_STATE_EVENT));
}
}
export function getStoredAccessToken() {
if (!canUseLocalStorage()) {
return '';
}
return window.localStorage.getItem(ACCESS_TOKEN_KEY)?.trim() || '';
}
export function setStoredAccessToken(
token: string,
options: {
emit?: boolean;
} = {},
) {
if (!canUseLocalStorage()) {
return;
}
const nextToken = token.trim();
const previousToken = getStoredAccessToken();
if (nextToken) {
window.localStorage.setItem(ACCESS_TOKEN_KEY, nextToken);
} else {
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
}
// 只有登录态令牌真的发生变化时才广播事件,避免无意义的鉴权重算。
if (options.emit !== false && previousToken !== nextToken) {
emitAuthStateChange();
}
}
export function clearStoredAccessToken(
options: {
emit?: boolean;
} = {},
) {
if (!canUseLocalStorage()) {
return;
}
const previousToken = getStoredAccessToken();
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
// 未登录态下重复清空 token 不应触发状态刷新,否则会放大 401 循环。
if (options.emit !== false && previousToken) {
emitAuthStateChange();
}
}
function withAuthorizationHeaders(
headers?: HeadersInit,
options: Pick<ApiRequestOptions, 'omitEnvelopeHeader' | 'skipAuth'> = {},
) {
const nextHeaders = attachHostRuntimeHeaders(normalizeHeaders(headers));
const token = getStoredAccessToken();
if (token && !options.skipAuth) {
nextHeaders.Authorization = `Bearer ${token}`;
}
if (!options.omitEnvelopeHeader) {
nextHeaders[API_RESPONSE_ENVELOPE_HEADER] = API_RESPONSE_ENVELOPE_VERSION;
}
return nextHeaders;
}
let refreshAccessTokenPromise: Promise<string> | null = null;
function shouldClearAuthAfterRefreshFailure(error: unknown) {
return (
error instanceof ApiClientError &&
(error.status === 401 || error.status === 403)
);
}
async function refreshAccessToken() {
if (refreshAccessTokenPromise) {
return refreshAccessTokenPromise;
}
refreshAccessTokenPromise = (async () => {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'same-origin',
headers: attachHostRuntimeHeaders({
[API_RESPONSE_ENVELOPE_HEADER]: API_RESPONSE_ENVELOPE_VERSION,
}),
});
if (!response.ok) {
throw await buildApiClientError(response, '刷新登录状态失败');
}
const responseText = await response.text();
const payload = responseText
? unwrapApiResponse<AuthRefreshResponse>(
JSON.parse(responseText) as AuthRefreshResponse,
)
: null;
const nextToken = payload?.token?.trim();
if (!nextToken) {
throw new Error('刷新登录状态失败');
}
setStoredAccessToken(nextToken, { emit: false });
return nextToken;
})();
try {
return await refreshAccessTokenPromise;
} finally {
refreshAccessTokenPromise = null;
}
}
export async function ensureStoredAccessToken() {
const currentToken = getStoredAccessToken();
if (currentToken) {
return currentToken;
}
// AuthGate 恢复会话时可能只有 HttpOnly refresh cookie,本地尚无 access token。
return refreshAccessToken();
}
export async function refreshStoredAccessToken(
options: {
clearOnFailure?: boolean;
} = {},
) {
try {
return await refreshAccessToken();
} catch (error) {
if (
options.clearOnFailure !== false &&
shouldClearAuthAfterRefreshFailure(error)
) {
clearStoredAccessToken({ emit: false });
}
throw error;
}
}
export async function fetchWithApiAuth(
input: string,
init: RequestInit = {},
options: ApiRequestOptions = {},
) {
const method = (init.method ?? 'GET').toUpperCase();
const retry = resolveRetryOptions(method, options.retry);
const authFailurePolicy = resolveAuthFailurePolicy(options);
const requestSignal = init.signal ?? undefined;
const requestId = resolveRequestIdHeader(normalizeHeaders(init.headers), options);
let attempt = 0;
let refreshAttempted = false;
for (;;) {
try {
let requestHeaders = withAuthorizationHeaders(init.headers, options);
requestHeaders[REQUEST_ID_HEADER] = requestId;
let hasAuthHeader = Boolean(
requestHeaders.Authorization?.trim() ||
requestHeaders.authorization?.trim(),
);
if (
!hasAuthHeader &&
!options.skipAuth &&
!authFailurePolicy.skipRefresh
) {
try {
// 受保护请求在本地 access token 缺失时,先尝试用 refresh cookie 静默补票,
// 避免把后端原始 “缺少 Bearer Token” 直接暴露给业务 UI。
await ensureStoredAccessToken();
requestHeaders = withAuthorizationHeaders(init.headers, options);
requestHeaders[REQUEST_ID_HEADER] = requestId;
hasAuthHeader = Boolean(
requestHeaders.Authorization?.trim() ||
requestHeaders.authorization?.trim(),
);
} catch {
// 补票失败时继续走原始请求,让调用方按真实 401 分支处理。
}
}
const timedRequest = composeAbortSignal(requestSignal, options.timeoutMs);
let response: Response;
try {
response = await fetch(input, {
credentials: 'same-origin',
...init,
signal: timedRequest.signal,
headers: requestHeaders,
});
} finally {
timedRequest.cleanup();
}
if (
response.status === 401 &&
hasAuthHeader &&
!options.skipAuth &&
!authFailurePolicy.skipRefresh &&
!refreshAttempted
) {
try {
await refreshAccessToken();
refreshAttempted = true;
// refresh 成功只代表 access token 已补票成功,
// 不能把当前业务请求的首次 401 直接放大成全局鉴权变更,
// 否则像 Puzzle works 这类受保护列表会把单接口失败放大成整个平台重复 hydrate。
continue;
} catch (refreshError) {
const shouldClearAuth =
hasAuthHeader &&
authFailurePolicy.clearAuthOnUnauthorized &&
shouldClearAuthAfterRefreshFailure(refreshError);
if (shouldClearAuth) {
clearStoredAccessToken({ emit: false });
}
if (shouldClearAuth && authFailurePolicy.notifyAuthStateChange) {
emitAuthStateChange();
}
}
} else if (
response.status === 401 &&
hasAuthHeader &&
!options.skipAuth &&
!refreshAttempted
) {
if (authFailurePolicy.clearAuthOnUnauthorized) {
clearStoredAccessToken({ emit: false });
}
if (authFailurePolicy.notifyAuthStateChange) {
emitAuthStateChange();
}
}
if (!shouldRetryResponse(response.status, attempt, retry)) {
return response;
}
} catch (error) {
if (!shouldRetryError(error, attempt, retry)) {
throw error;
}
}
attempt += 1;
await waitForRetry(buildRetryDelayMs(attempt, retry), requestSignal);
}
}
async function buildApiClientError(
response: Response,
fallbackMessage: string,
) {
const responseText = await response.text();
const parsedError = parseApiErrorShape(responseText);
const requestId =
parsedError?.meta.requestId ??
response.headers.get(REQUEST_ID_HEADER) ??
undefined;
const baseMessage = parseApiErrorMessage(responseText, fallbackMessage);
return new ApiClientError({
message: requestId ? `${baseMessage}requestId: ${requestId}` : baseMessage,
status: response.status,
code: parsedError?.code ?? `HTTP_${response.status || 0}`,
details: parsedError?.details ?? null,
meta: {
apiVersion:
parsedError?.meta.apiVersion ??
response.headers.get(API_VERSION_HEADER) ??
API_VERSION,
requestId,
routeVersion:
parsedError?.meta.routeVersion ??
response.headers.get(ROUTE_VERSION_HEADER) ??
undefined,
operation: parsedError?.meta.operation,
latencyMs: parsedError?.meta.latencyMs,
timestamp: parsedError?.meta.timestamp,
},
responseText,
});
}
export async function requestJson<T>(
url: string,
init: RequestInit,
fallbackMessage: string,
options: ApiRequestOptions = {},
): Promise<T> {
const response = await fetchWithApiAuth(url, init, options);
if (!response.ok) {
throw await buildApiClientError(response, fallbackMessage);
}
const responseText = await response.text();
return responseText
? unwrapApiResponse<T>(JSON.parse(responseText) as T)
: (null as T);
}