子账号登录微信授权
This commit is contained in:
+191
-40
@@ -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;
|
||||
|
||||
@@ -2,14 +2,15 @@ import type { ClientRuntimeConfig, WechatLoginResult } from '@dukang/shared-type
|
||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
|
||||
import { isWechatEnv, weixinSdk } from './weixin';
|
||||
import { request, saveAuth } from './api';
|
||||
import { request, saveWechatSession, type PartnerSessionPayload } from './api';
|
||||
|
||||
export type PartnerProfile = {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
companyName: string;
|
||||
companyName?: string;
|
||||
hasWechat?: boolean;
|
||||
isPrimary?: boolean;
|
||||
};
|
||||
|
||||
export async function fetchClientConfig(): Promise<ClientRuntimeConfig> {
|
||||
@@ -20,7 +21,7 @@ export async function fetchPartnerProfile(): Promise<PartnerProfile> {
|
||||
return request<PartnerProfile>('PARTNER_H5', '/partner/me');
|
||||
}
|
||||
|
||||
/** 微信内上传照片前需完成公众号授权绑定 */
|
||||
/** 微信内上传照片前需完成公众号 OAuth 绑定 */
|
||||
export function needsWechatAuth(
|
||||
profile: PartnerProfile | null,
|
||||
config?: Pick<ClientRuntimeConfig, 'wxAuthorize'> | null,
|
||||
@@ -34,24 +35,30 @@ export async function checkNeedsWechatAuth(profile: PartnerProfile | null): Prom
|
||||
return needsWechatAuth(profile, config);
|
||||
}
|
||||
|
||||
/** 处理微信登录/绑定结果,返回是否已拿到 token 可进入首页 */
|
||||
export function handlePartnerWechatLoginResult(result: WechatLoginResult): boolean {
|
||||
if (!result.accessToken) return false;
|
||||
saveAuth({ accessToken: result.accessToken });
|
||||
return true;
|
||||
export function sessionFromWechatLogin(result: WechatLoginResult): PartnerSessionPayload | null {
|
||||
if (!result.accessToken || !result.refreshToken) return null;
|
||||
const partner = result.partner;
|
||||
return {
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken,
|
||||
partner: partner
|
||||
? {
|
||||
id: String(partner.id ?? ''),
|
||||
name: String(partner.name ?? ''),
|
||||
phone: String(partner.phone ?? ''),
|
||||
companyName: partner.companyName ? String(partner.companyName) : undefined,
|
||||
isPrimary: partner.isPrimary !== false,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** 微信登录成功后拉取并缓存合伙人资料,供一键登录页展示 */
|
||||
export async function persistPartnerProfileAfterLogin(): Promise<void> {
|
||||
try {
|
||||
const profile = await fetchPartnerProfile();
|
||||
saveAuth({
|
||||
accessToken: localStorage.getItem('accessToken') ?? '',
|
||||
partner: profile,
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
/** 处理微信登录/绑定结果,写入 7 天免登录 session */
|
||||
export function handlePartnerWechatLoginResult(result: WechatLoginResult): PartnerSessionPayload | null {
|
||||
const session = sessionFromWechatLogin(result);
|
||||
if (!session) return null;
|
||||
saveWechatSession(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function handlePartnerWechatCallback(): Promise<WechatLoginResult | null> {
|
||||
@@ -62,12 +69,12 @@ export async function handlePartnerWechatCallback(): Promise<WechatLoginResult |
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信授权登录(对齐 C 端:仅微信内置浏览器走 OAuth)。
|
||||
* 返回 true = 已登录;void = 已跳转授权页等待回调。
|
||||
* 微信一键登录(已绑定微信的合伙人账号免验证码)。
|
||||
* 返回 session = 已登录;void = 已跳转授权页等待回调。
|
||||
*/
|
||||
export async function loginPartnerWithWechat(): Promise<boolean | void> {
|
||||
export async function loginPartnerWithWechat(): Promise<PartnerSessionPayload | null | void> {
|
||||
const config = await fetchClientConfig();
|
||||
if (!isWxAuthorizeEnabled(config)) return false;
|
||||
if (!isWxAuthorizeEnabled(config)) return null;
|
||||
if (!isWechatEnv()) {
|
||||
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
|
||||
}
|
||||
@@ -75,6 +82,14 @@ export async function loginPartnerWithWechat(): Promise<boolean | void> {
|
||||
if (result) return handlePartnerWechatLoginResult(result);
|
||||
}
|
||||
|
||||
/** 短信登录成功后于微信内自动发起 OAuth,绑定 openId 便于后续免登 */
|
||||
export async function bindPartnerWechatAfterSmsLogin(): Promise<void> {
|
||||
const config = await fetchClientConfig();
|
||||
if (!isWxAuthorizeEnabled(config)) return;
|
||||
if (!isWechatEnv()) return;
|
||||
await weixinSdk.login();
|
||||
}
|
||||
|
||||
export async function authorizePartnerWechat(): Promise<WechatLoginResult | void> {
|
||||
const config = await fetchClientConfig();
|
||||
if (!isWxAuthorizeEnabled(config)) return;
|
||||
@@ -84,7 +99,14 @@ export async function authorizePartnerWechat(): Promise<WechatLoginResult | void
|
||||
return weixinSdk.login();
|
||||
}
|
||||
|
||||
/** @deprecated 使用 handlePartnerWechatLoginResult */
|
||||
export function savePartnerWechatAuth(result: WechatLoginResult): boolean {
|
||||
/** OAuth 回跳统一处理(登录页 / 录店页等) */
|
||||
export async function processPartnerWechatOAuthCallback(): Promise<PartnerSessionPayload | null> {
|
||||
const result = await handlePartnerWechatCallback();
|
||||
if (!result) return null;
|
||||
return handlePartnerWechatLoginResult(result);
|
||||
}
|
||||
|
||||
/** @deprecated 使用 handlePartnerWechatLoginResult */
|
||||
export function savePartnerWechatAuth(result: WechatLoginResult): boolean {
|
||||
return !!handlePartnerWechatLoginResult(result);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user