341 lines
9.8 KiB
TypeScript
341 lines
9.8 KiB
TypeScript
import { reportApiError } from '@dukang/client-logging';
|
||
|
||
export const apiBase = '/api/v1';
|
||
const CLIENT_APP = 'SHOP_H5';
|
||
|
||
export type ShopStoreOption = {
|
||
storeId: string;
|
||
name: string;
|
||
status: string;
|
||
district?: string;
|
||
address?: string;
|
||
};
|
||
|
||
export type StoreSessionStore = {
|
||
id: string;
|
||
storeId: string;
|
||
name: string;
|
||
phone: string;
|
||
storeName: string;
|
||
isPrimary?: boolean;
|
||
stores?: ShopStoreOption[];
|
||
};
|
||
|
||
export type StoreProfile = {
|
||
id: string;
|
||
storeId: string;
|
||
name: string;
|
||
phone: string;
|
||
status?: string;
|
||
isPrimary?: boolean;
|
||
account?: {
|
||
id: string;
|
||
name: string;
|
||
phone: string;
|
||
isPrimary: boolean;
|
||
hasWechat?: boolean;
|
||
};
|
||
store?: ShopStoreOption | { id: string; name: string } | null;
|
||
stores?: ShopStoreOption[];
|
||
};
|
||
|
||
export type ShopSessionPayload = {
|
||
accessToken: string;
|
||
refreshToken: string;
|
||
store?: StoreSessionStore;
|
||
stores?: ShopStoreOption[];
|
||
account?: StoreProfile['account'];
|
||
selectedStoreId?: string;
|
||
};
|
||
|
||
const ACCESS_TOKEN = 'accessToken';
|
||
const REFRESH_TOKEN = 'refreshToken';
|
||
const LAST_PHONE = 'shopLastPhone';
|
||
const STORE_PROFILE = 'shopStoreProfile';
|
||
const SESSION_EXPIRES_AT = 'shopSessionExpiresAt';
|
||
export const SHOP_WX_BOUND = 'shopWxBound';
|
||
|
||
/** 手机号或微信验证通过后的免登录时长 */
|
||
export const SHOP_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||
|
||
const AUTH_RECOVERY_EXEMPT_PATHS = [
|
||
'/shop/auth/token/refresh',
|
||
'/shop/auth/sms/send',
|
||
'/shop/auth/login/sms',
|
||
'/shop/auth/login/wechat',
|
||
];
|
||
|
||
export function getLastPhone() {
|
||
return localStorage.getItem(LAST_PHONE) ?? '';
|
||
}
|
||
|
||
export function getStoreProfile(): StoreSessionStore | null {
|
||
try {
|
||
const raw = localStorage.getItem(STORE_PROFILE);
|
||
return raw ? (JSON.parse(raw) as StoreSessionStore) : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export function hasShopWxSession() {
|
||
return localStorage.getItem(SHOP_WX_BOUND) === '1';
|
||
}
|
||
|
||
export function isShopSessionExpired() {
|
||
const raw = localStorage.getItem(SESSION_EXPIRES_AT);
|
||
if (!raw) return false;
|
||
return Date.now() > Number(raw);
|
||
}
|
||
|
||
export function touchShopSession() {
|
||
if (!localStorage.getItem(REFRESH_TOKEN)) return;
|
||
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + SHOP_SESSION_TTL_MS));
|
||
}
|
||
|
||
export function saveAuth(data: ShopSessionPayload) {
|
||
localStorage.setItem(ACCESS_TOKEN, data.accessToken);
|
||
if (data.refreshToken) localStorage.setItem(REFRESH_TOKEN, data.refreshToken);
|
||
if (data.store) {
|
||
const profile: StoreSessionStore = {
|
||
...data.store,
|
||
stores: data.stores ?? data.store.stores,
|
||
isPrimary: data.account?.isPrimary ?? data.store.isPrimary,
|
||
};
|
||
localStorage.setItem(STORE_PROFILE, JSON.stringify(profile));
|
||
if (data.store.phone) localStorage.setItem(LAST_PHONE, data.store.phone);
|
||
} else if (data.account) {
|
||
localStorage.setItem(
|
||
STORE_PROFILE,
|
||
JSON.stringify({
|
||
id: data.account.id,
|
||
storeId: '',
|
||
name: data.account.name,
|
||
phone: data.account.phone,
|
||
storeName: '',
|
||
isPrimary: data.account.isPrimary,
|
||
stores: data.stores ?? [],
|
||
} satisfies StoreSessionStore),
|
||
);
|
||
localStorage.setItem(LAST_PHONE, data.account.phone);
|
||
}
|
||
}
|
||
|
||
/** 手机号验证成功后写入 7 天免验证码会话 */
|
||
export function saveRememberedSession(data: ShopSessionPayload) {
|
||
saveAuth(data);
|
||
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + SHOP_SESSION_TTL_MS));
|
||
}
|
||
|
||
/** 微信登录/绑定成功后写入 7 天免登录 session */
|
||
export function saveWechatSession(data: ShopSessionPayload) {
|
||
saveRememberedSession(data);
|
||
localStorage.setItem(SHOP_WX_BOUND, '1');
|
||
}
|
||
|
||
export function clearAuth(options?: { keepProfile?: boolean }) {
|
||
localStorage.removeItem(ACCESS_TOKEN);
|
||
localStorage.removeItem(REFRESH_TOKEN);
|
||
localStorage.removeItem(SESSION_EXPIRES_AT);
|
||
localStorage.removeItem(SHOP_WX_BOUND);
|
||
if (!options?.keepProfile) {
|
||
localStorage.removeItem(STORE_PROFILE);
|
||
localStorage.removeItem(LAST_PHONE);
|
||
}
|
||
}
|
||
|
||
export function isLoggedIn() {
|
||
return !!localStorage.getItem(ACCESS_TOKEN);
|
||
}
|
||
|
||
function profileFromMe(me: StoreProfile): StoreSessionStore {
|
||
const selected =
|
||
me.store && 'storeId' in me.store
|
||
? me.store
|
||
: me.store && 'id' in me.store
|
||
? { storeId: String((me.store as { id: string }).id), name: me.store.name }
|
||
: null;
|
||
return {
|
||
id: me.account?.id ?? me.id,
|
||
storeId: selected?.storeId ?? me.storeId ?? '',
|
||
name: me.account?.name ?? me.name,
|
||
phone: me.account?.phone ?? me.phone,
|
||
storeName: selected?.name ?? '',
|
||
isPrimary: me.account?.isPrimary ?? me.isPrimary,
|
||
stores: me.stores ?? [],
|
||
};
|
||
}
|
||
|
||
export function needsStoreSelection(session: {
|
||
store?: StoreSessionStore | null;
|
||
stores?: ShopStoreOption[];
|
||
selectedStoreId?: string;
|
||
}): boolean {
|
||
const storeId = session.store?.storeId || session.selectedStoreId || '';
|
||
const stores = session.stores ?? session.store?.stores ?? [];
|
||
if (stores.length > 1 && !storeId) return true;
|
||
if (!storeId && stores.length !== 1) return true;
|
||
return false;
|
||
}
|
||
|
||
export async function selectStore(storeId: string): Promise<ShopSessionPayload> {
|
||
const data = await requestWithAuthRetry<ShopSessionPayload>('/shop/auth/select-store', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ storeId }),
|
||
});
|
||
saveAuth(data);
|
||
touchShopSession();
|
||
return data;
|
||
}
|
||
|
||
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 });
|
||
let json: { code: number; message?: string; data?: T; reason?: string };
|
||
try {
|
||
json = await res.json();
|
||
} catch {
|
||
const err = new Error(res.ok ? '接口返回异常' : `网络异常(HTTP ${res.status})`) as Error & {
|
||
status?: number;
|
||
};
|
||
err.status = res.status;
|
||
throw err;
|
||
}
|
||
if (json.code !== 0) {
|
||
const err = new Error(json.message || '请求失败') as Error & {
|
||
status?: number;
|
||
reason?: string;
|
||
};
|
||
err.status = res.status >= 500 ? res.status : json.code;
|
||
err.reason = json.reason;
|
||
if (json.code === 400) {
|
||
reportApiError(
|
||
{ apiBase, clientApp: CLIENT_APP, getToken: () => localStorage.getItem(ACCESS_TOKEN) },
|
||
{ message: json.message || '请求失败', status: 400, url: path, category: 'validation_error' },
|
||
);
|
||
}
|
||
throw err;
|
||
}
|
||
return json.data as T;
|
||
}
|
||
|
||
async function refreshSession(): Promise<ShopSessionPayload | null> {
|
||
const refreshToken = localStorage.getItem(REFRESH_TOKEN);
|
||
if (!refreshToken) return null;
|
||
try {
|
||
const data = await rawRequest<ShopSessionPayload>(
|
||
'/shop/auth/token/refresh',
|
||
{
|
||
method: 'POST',
|
||
body: JSON.stringify({ refreshToken }),
|
||
},
|
||
null,
|
||
);
|
||
saveAuth(data);
|
||
touchShopSession();
|
||
return data;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
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; reason?: string };
|
||
// 账号停用 / 门店关闭 / 合伙人绑定失效:强制退出登录
|
||
if (err.reason === 'ACCOUNT_DISABLED') {
|
||
clearAuth();
|
||
if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) {
|
||
window.location.replace('/login?disabled=1');
|
||
}
|
||
throw e;
|
||
}
|
||
const canRecover =
|
||
err.status === 401 &&
|
||
!retried &&
|
||
!AUTH_RECOVERY_EXEMPT_PATHS.some((p) => path.startsWith(p));
|
||
if (!canRecover) throw e;
|
||
const refreshed = await refreshSession();
|
||
if (!refreshed) {
|
||
clearAuth();
|
||
throw e;
|
||
}
|
||
return requestWithAuthRetry<T>(path, options, true);
|
||
}
|
||
}
|
||
|
||
export async function request<T>(clientApp: string, path: string, options: RequestInit = {}): Promise<T> {
|
||
void clientApp;
|
||
return requestWithAuthRetry<T>(path, options);
|
||
}
|
||
|
||
export async function ensureSession(): Promise<{
|
||
authenticated: boolean;
|
||
store: StoreSessionStore | null;
|
||
needsSelectStore: boolean;
|
||
}> {
|
||
if (!isLoggedIn()) {
|
||
return { authenticated: false, store: null, needsSelectStore: false };
|
||
}
|
||
if (isShopSessionExpired()) {
|
||
clearAuth({ keepProfile: true });
|
||
return { authenticated: false, store: getStoreProfile(), needsSelectStore: false };
|
||
}
|
||
try {
|
||
const me = await rawRequest<StoreProfile>('/shop/auth/me');
|
||
const store = profileFromMe(me);
|
||
saveAuth({
|
||
accessToken: localStorage.getItem(ACCESS_TOKEN) ?? '',
|
||
refreshToken: localStorage.getItem(REFRESH_TOKEN) ?? '',
|
||
store,
|
||
stores: me.stores,
|
||
account: me.account,
|
||
});
|
||
touchShopSession();
|
||
return {
|
||
authenticated: true,
|
||
store,
|
||
needsSelectStore: needsStoreSelection({ store, stores: me.stores }),
|
||
};
|
||
} catch (e) {
|
||
const err = e as Error & { status?: number; reason?: string };
|
||
if (err.reason === 'ACCOUNT_DISABLED') {
|
||
clearAuth();
|
||
if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) {
|
||
window.location.replace('/login?disabled=1');
|
||
}
|
||
return { authenticated: false, store: null, needsSelectStore: false };
|
||
}
|
||
if (err.status === 401) {
|
||
const refreshed = await refreshSession();
|
||
if (refreshed) {
|
||
return {
|
||
authenticated: true,
|
||
store: refreshed.store ?? getStoreProfile(),
|
||
needsSelectStore: needsStoreSelection(refreshed),
|
||
};
|
||
}
|
||
clearAuth({ keepProfile: true });
|
||
return { authenticated: false, store: getStoreProfile(), needsSelectStore: false };
|
||
}
|
||
throw e;
|
||
}
|
||
}
|