Files
dukang/apps/h5-shop/src/lib/api.ts
T
jacy 233ed0af3b
CI / verify (pull_request) Has been cancelled
v3.5.1 版本更新
2026-08-19 15:54:51 +08:00

341 lines
9.8 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 { 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;
}
}