子账号登录微信授权

This commit is contained in:
2026-07-10 11:03:11 +08:00
parent f4f4f1b4aa
commit 9c5cb69730
11 changed files with 535 additions and 251 deletions
+191 -40
View File
@@ -3,14 +3,30 @@ import { isOnAppPath, toAppPath } from '@dukang/weixin-sdk';
import { showPartnerToast } from './toast';
export const apiBase = '/api/v1';
const CLIENT_APP = 'PARTNER_H5';
const ACCESS_TOKEN = 'accessToken';
const REFRESH_TOKEN = 'refreshToken';
const LAST_PHONE = 'partnerLastPhone';
const PARTNER_PROFILE = 'partnerProfile';
const SESSION_EXPIRES_AT = 'partnerSessionExpiresAt';
export const PARTNER_WX_BOUND = 'partnerWxBound';
export type PartnerSessionProfile = Pick<PartnerMe, 'id' | 'name' | 'phone' | 'companyName'>;
/** 微信验证通过后的免登录时长 */
export const PARTNER_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
export type PartnerAuthPayload = {
const AUTH_RECOVERY_EXEMPT_PATHS = [
'/partner/auth/token/refresh',
'/partner/auth/sms/send',
'/partner/auth/login/sms',
'/partner/auth/login/wechat',
];
export type PartnerSessionProfile = Pick<PartnerMe, 'id' | 'name' | 'phone' | 'companyName' | 'isPrimary'>;
export type PartnerSessionPayload = {
accessToken: string;
refreshToken: string;
partner?: PartnerSessionProfile;
};
@@ -32,53 +48,188 @@ export function getPartnerProfile(): PartnerSessionProfile | null {
}
}
export async function request<T>(
clientApp: string,
path: string,
options: ApiRequestOptions = {},
): Promise<T> {
const { silent, ...fetchOptions } = options;
const token = localStorage.getItem('accessToken');
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Client-App': clientApp,
...(fetchOptions.headers as Record<string, string>),
};
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(`${apiBase}${path}`, { ...fetchOptions, headers });
const json = await res.json().catch(() => ({ code: res.status, message: '网络异常' }));
const message = json.message || (res.status === 401 ? '登录已过期,请重新登录' : '请求失败');
if (res.status === 401 || json.code === 401) {
if (token && localStorage.getItem('accessToken') === token) {
clearAuth();
if (!silent) showPartnerToast(message, 'error');
if (typeof window !== 'undefined' && !isOnAppPath('/login')) {
window.location.href = toAppPath('/login');
}
}
throw new Error(message);
}
if (json.code !== 0) {
if (!silent) showPartnerToast(message, 'error');
throw new Error(message);
}
return json.data as T;
export function hasPartnerWxSession() {
return localStorage.getItem(PARTNER_WX_BOUND) === '1';
}
export function saveAuth(data: PartnerAuthPayload) {
localStorage.setItem('accessToken', data.accessToken);
export function isPartnerSessionExpired() {
const raw = localStorage.getItem(SESSION_EXPIRES_AT);
if (!raw) return false;
return Date.now() > Number(raw);
}
export function touchPartnerSession() {
if (!hasPartnerWxSession()) return;
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS));
}
export function saveAuth(data: PartnerSessionPayload) {
localStorage.setItem(ACCESS_TOKEN, data.accessToken);
if (data.refreshToken) localStorage.setItem(REFRESH_TOKEN, data.refreshToken);
if (data.partner) {
localStorage.setItem(PARTNER_PROFILE, JSON.stringify(data.partner));
localStorage.setItem(LAST_PHONE, data.partner.phone);
}
}
export function clearAuth() {
localStorage.removeItem('accessToken');
localStorage.removeItem(PARTNER_PROFILE);
/** 微信登录/绑定成功后写入 7 天免登录 session */
export function saveWechatSession(data: PartnerSessionPayload) {
saveAuth(data);
localStorage.setItem(PARTNER_WX_BOUND, '1');
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS));
}
export function clearAuth(options?: { keepProfile?: boolean }) {
localStorage.removeItem(ACCESS_TOKEN);
localStorage.removeItem(REFRESH_TOKEN);
localStorage.removeItem(SESSION_EXPIRES_AT);
localStorage.removeItem(PARTNER_WX_BOUND);
if (!options?.keepProfile) {
localStorage.removeItem(PARTNER_PROFILE);
localStorage.removeItem(LAST_PHONE);
}
}
export function isLoggedIn() {
return !!localStorage.getItem('accessToken');
return !!localStorage.getItem(ACCESS_TOKEN);
}
function profileFromMe(me: PartnerMe): PartnerSessionProfile {
return {
id: me.id,
name: me.name,
phone: me.phone,
companyName: me.companyName,
isPrimary: me.isPrimary,
};
}
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().catch(() => ({ code: res.status, message: '网络异常' }));
if (json.code !== 0) {
const err = new Error(json.message || '请求失败') as Error & { status?: number };
err.status = json.code === 401 ? 401 : json.code;
throw err;
}
return json.data as T;
}
async function refreshSession(): Promise<PartnerSessionPayload | null> {
const refreshToken = localStorage.getItem(REFRESH_TOKEN);
if (!refreshToken) return null;
try {
const data = await rawRequest<PartnerSessionPayload>(
'/partner/auth/token/refresh',
{
method: 'POST',
body: JSON.stringify({ refreshToken }),
},
null,
);
saveAuth(data);
touchPartnerSession();
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 };
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({ keepProfile: true });
throw e;
}
return requestWithAuthRetry<T>(path, options, true);
}
}
export async function request<T>(
clientApp: string,
path: string,
options: ApiRequestOptions = {},
): Promise<T> {
void clientApp;
const { silent, ...fetchOptions } = options;
try {
return await requestWithAuthRetry<T>(path, fetchOptions);
} catch (e) {
const err = e as Error & { status?: number };
const message = err.message || '请求失败';
if (err.status === 401) {
if (localStorage.getItem(ACCESS_TOKEN)) {
clearAuth({ keepProfile: true });
if (!silent) showPartnerToast(message, 'error');
if (typeof window !== 'undefined' && !isOnAppPath('/login')) {
window.location.href = toAppPath('/login');
}
}
} else if (!silent) {
showPartnerToast(message, 'error');
}
throw e;
}
}
export async function ensureSession(): Promise<{ authenticated: boolean; partner: PartnerSessionProfile | null }> {
if (!isLoggedIn()) {
return { authenticated: false, partner: null };
}
if (isPartnerSessionExpired()) {
clearAuth({ keepProfile: true });
return { authenticated: false, partner: getPartnerProfile() };
}
try {
const me = await rawRequest<PartnerMe>('/partner/me');
const partner = profileFromMe(me);
saveAuth({
accessToken: localStorage.getItem(ACCESS_TOKEN) ?? '',
refreshToken: localStorage.getItem(REFRESH_TOKEN) ?? '',
partner,
});
touchPartnerSession();
return { authenticated: true, partner };
} catch (e) {
const err = e as Error & { status?: number };
if (err.status === 401) {
const refreshed = await refreshSession();
if (refreshed?.partner) {
return { authenticated: true, partner: refreshed.partner };
}
clearAuth({ keepProfile: true });
return { authenticated: false, partner: getPartnerProfile() };
}
const cached = getPartnerProfile();
if (cached) return { authenticated: true, partner: cached };
throw e;
}
}
/** @deprecated 使用 PartnerSessionPayload */
export type PartnerAuthPayload = PartnerSessionPayload;