189 lines
4.9 KiB
TypeScript
189 lines
4.9 KiB
TypeScript
export const BRAND = {
|
|
red: '#A02D30',
|
|
yellow: '#FFC107',
|
|
bg: '#f5f5f5',
|
|
text: '#333',
|
|
muted: '#999',
|
|
};
|
|
|
|
export const apiBase = '/api/v1';
|
|
const CLIENT_APP = 'USER_H5';
|
|
|
|
export type UserProfile = {
|
|
id: string;
|
|
userNo: string;
|
|
phone: string | null;
|
|
phoneVerified: boolean;
|
|
nickname: string | null;
|
|
avatarUrl: string | null;
|
|
hasWechat: boolean;
|
|
};
|
|
|
|
export type SessionPayload = {
|
|
accessToken: string;
|
|
refreshToken: string;
|
|
deviceKey?: string;
|
|
phoneVerified: boolean;
|
|
user?: UserProfile;
|
|
};
|
|
|
|
const DEVICE_KEY = 'deviceKey';
|
|
const ACCESS_TOKEN = 'accessToken';
|
|
const REFRESH_TOKEN = 'refreshToken';
|
|
|
|
export function getDeviceKey() {
|
|
return localStorage.getItem(DEVICE_KEY);
|
|
}
|
|
|
|
export function saveSession(data: SessionPayload) {
|
|
localStorage.setItem(ACCESS_TOKEN, data.accessToken);
|
|
localStorage.setItem(REFRESH_TOKEN, data.refreshToken);
|
|
if (data.deviceKey) localStorage.setItem(DEVICE_KEY, data.deviceKey);
|
|
}
|
|
|
|
export function saveAuth(data: { accessToken: string; refreshToken?: string; deviceKey?: string }) {
|
|
localStorage.setItem(ACCESS_TOKEN, data.accessToken);
|
|
if (data.refreshToken) localStorage.setItem(REFRESH_TOKEN, data.refreshToken);
|
|
if (data.deviceKey) localStorage.setItem(DEVICE_KEY, data.deviceKey);
|
|
}
|
|
|
|
export function clearAuth() {
|
|
localStorage.removeItem(ACCESS_TOKEN);
|
|
localStorage.removeItem(REFRESH_TOKEN);
|
|
}
|
|
|
|
export function isLoggedIn() {
|
|
return !!localStorage.getItem(ACCESS_TOKEN);
|
|
}
|
|
|
|
const AUTH_RECOVERY_EXEMPT_PATHS = ['/auth/session/bootstrap', '/auth/token/refresh'];
|
|
|
|
async function recoverSession(): Promise<SessionPayload> {
|
|
const refreshed = await refreshSession();
|
|
if (refreshed) return refreshed;
|
|
clearAuth();
|
|
return bootstrapSession();
|
|
}
|
|
|
|
async function rawRequest<T>(
|
|
path: string,
|
|
options: RequestInit = {},
|
|
token?: string | null,
|
|
): Promise<T> {
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
'X-Client-App': CLIENT_APP,
|
|
...(options.headers as Record<string, string>),
|
|
};
|
|
const authToken = token ?? localStorage.getItem(ACCESS_TOKEN);
|
|
if (authToken) headers.Authorization = `Bearer ${authToken}`;
|
|
|
|
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
|
|
const json = await res.json();
|
|
if (json.code !== 0) {
|
|
const err = new Error(json.message || '请求失败') as Error & { status?: number };
|
|
err.status = json.code;
|
|
throw err;
|
|
}
|
|
return json.data as T;
|
|
}
|
|
|
|
async function requestWithAuthRetry<T>(
|
|
path: string,
|
|
options: RequestInit = {},
|
|
retried = false,
|
|
): Promise<T> {
|
|
try {
|
|
return await rawRequest<T>(path, options);
|
|
} catch (e) {
|
|
const err = e as Error & { status?: number };
|
|
const canRecover =
|
|
err.status === 401 &&
|
|
!retried &&
|
|
!AUTH_RECOVERY_EXEMPT_PATHS.some((p) => path.startsWith(p));
|
|
if (!canRecover) throw e;
|
|
await recoverSession();
|
|
return requestWithAuthRetry<T>(path, options, true);
|
|
}
|
|
}
|
|
|
|
export async function request<T>(
|
|
_clientApp: string,
|
|
path: string,
|
|
options: RequestInit = {},
|
|
): Promise<T> {
|
|
if (!isLoggedIn() && !AUTH_RECOVERY_EXEMPT_PATHS.some((p) => path.startsWith(p))) {
|
|
await bootstrapSession();
|
|
}
|
|
return requestWithAuthRetry<T>(path, options);
|
|
}
|
|
|
|
export async function bootstrapSession(): Promise<SessionPayload> {
|
|
const deviceKey = getDeviceKey();
|
|
const data = await rawRequest<SessionPayload>(
|
|
'/auth/session/bootstrap',
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify(deviceKey ? { deviceKey } : {}),
|
|
},
|
|
null,
|
|
);
|
|
saveSession(data);
|
|
return data;
|
|
}
|
|
|
|
export async function refreshSession(): Promise<SessionPayload | null> {
|
|
const refreshToken = localStorage.getItem(REFRESH_TOKEN);
|
|
if (!refreshToken) return null;
|
|
try {
|
|
const data = await rawRequest<SessionPayload>(
|
|
'/auth/token/refresh',
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify({ refreshToken }),
|
|
},
|
|
null,
|
|
);
|
|
saveSession(data);
|
|
return data;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function ensureSession(): Promise<SessionPayload> {
|
|
if (isLoggedIn()) {
|
|
try {
|
|
const me = await rawRequest<UserProfile>('/auth/me');
|
|
return {
|
|
accessToken: localStorage.getItem(ACCESS_TOKEN) ?? '',
|
|
refreshToken: localStorage.getItem(REFRESH_TOKEN) ?? '',
|
|
deviceKey: getDeviceKey() ?? undefined,
|
|
phoneVerified: !!me.phoneVerified,
|
|
user: me,
|
|
};
|
|
} catch (e) {
|
|
const err = e as Error & { status?: number };
|
|
if (err.status === 401) {
|
|
clearAuth();
|
|
} else {
|
|
const refreshed = await refreshSession();
|
|
if (refreshed) return refreshed;
|
|
}
|
|
}
|
|
}
|
|
return bootstrapSession();
|
|
}
|
|
|
|
export async function bindPhone(phone: string, code: string): Promise<SessionPayload> {
|
|
if (!isLoggedIn()) {
|
|
await bootstrapSession();
|
|
}
|
|
const data = await requestWithAuthRetry<SessionPayload>('/auth/phone/bind', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ phone, code }),
|
|
});
|
|
saveSession(data);
|
|
return data;
|
|
}
|