门店端微信号自动登录

This commit is contained in:
2026-07-09 22:07:35 +08:00
parent e20ee1ec7f
commit 12ba19ede4
7 changed files with 210 additions and 59 deletions
+5
View File
@@ -1,4 +1,5 @@
import { Navigate, useLocation } from 'react-router-dom'; import { Navigate, useLocation } from 'react-router-dom';
import { getStoreProfile, hasShopWxSession } from '../lib/api';
import { useStoreSession } from '../contexts/StoreSessionContext'; import { useStoreSession } from '../contexts/StoreSessionContext';
const PUBLIC_PATHS = new Set(['/login']); const PUBLIC_PATHS = new Set(['/login']);
@@ -20,6 +21,10 @@ export default function AuthGate({ children }: { children: React.ReactNode }) {
} }
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) { if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
const profile = getStoreProfile();
if (profile && hasShopWxSession() && location.pathname !== '/login') {
return <Navigate to="/login?quick=1" replace state={{ from: location }} />;
}
return <Navigate to="/login" replace state={{ from: location }} />; return <Navigate to="/login" replace state={{ from: location }} />;
} }
+47 -4
View File
@@ -28,8 +28,18 @@ const ACCESS_TOKEN = 'accessToken';
const REFRESH_TOKEN = 'refreshToken'; const REFRESH_TOKEN = 'refreshToken';
const LAST_PHONE = 'shopLastPhone'; const LAST_PHONE = 'shopLastPhone';
const STORE_PROFILE = 'shopStoreProfile'; const STORE_PROFILE = 'shopStoreProfile';
const SESSION_EXPIRES_AT = 'shopSessionExpiresAt';
export const SHOP_WX_BOUND = 'shopWxBound';
const AUTH_RECOVERY_EXEMPT_PATHS = ['/shop/auth/token/refresh', '/shop/auth/sms/send', '/shop/auth/login/sms']; /** 微信验证通过后的免登录时长 */
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() { export function getLastPhone() {
return localStorage.getItem(LAST_PHONE) ?? ''; return localStorage.getItem(LAST_PHONE) ?? '';
@@ -44,6 +54,21 @@ export function getStoreProfile(): StoreSessionStore | 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 (!hasShopWxSession()) return;
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + SHOP_SESSION_TTL_MS));
}
export function saveAuth(data: ShopSessionPayload) { export function saveAuth(data: ShopSessionPayload) {
localStorage.setItem(ACCESS_TOKEN, data.accessToken); localStorage.setItem(ACCESS_TOKEN, data.accessToken);
if (data.refreshToken) localStorage.setItem(REFRESH_TOKEN, data.refreshToken); if (data.refreshToken) localStorage.setItem(REFRESH_TOKEN, data.refreshToken);
@@ -53,10 +78,22 @@ export function saveAuth(data: ShopSessionPayload) {
} }
} }
export function clearAuth() { /** 微信登录/绑定成功后写入 7 天免登录 session */
export function saveWechatSession(data: ShopSessionPayload) {
saveAuth(data);
localStorage.setItem(SHOP_WX_BOUND, '1');
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + SHOP_SESSION_TTL_MS));
}
export function clearAuth(options?: { keepProfile?: boolean }) {
localStorage.removeItem(ACCESS_TOKEN); localStorage.removeItem(ACCESS_TOKEN);
localStorage.removeItem(REFRESH_TOKEN); localStorage.removeItem(REFRESH_TOKEN);
localStorage.removeItem(SESSION_EXPIRES_AT);
localStorage.removeItem(SHOP_WX_BOUND);
if (!options?.keepProfile) {
localStorage.removeItem(STORE_PROFILE); localStorage.removeItem(STORE_PROFILE);
localStorage.removeItem(LAST_PHONE);
}
} }
export function isLoggedIn() { export function isLoggedIn() {
@@ -109,6 +146,7 @@ async function refreshSession(): Promise<ShopSessionPayload | null> {
null, null,
); );
saveAuth(data); saveAuth(data);
touchShopSession();
return data; return data;
} catch { } catch {
return null; return null;
@@ -147,6 +185,10 @@ export async function ensureSession(): Promise<{ authenticated: boolean; store:
if (!isLoggedIn()) { if (!isLoggedIn()) {
return { authenticated: false, store: null }; return { authenticated: false, store: null };
} }
if (isShopSessionExpired()) {
clearAuth({ keepProfile: true });
return { authenticated: false, store: getStoreProfile() };
}
try { try {
const me = await rawRequest<StoreProfile>('/shop/auth/me'); const me = await rawRequest<StoreProfile>('/shop/auth/me');
const store = profileFromMe(me); const store = profileFromMe(me);
@@ -155,6 +197,7 @@ export async function ensureSession(): Promise<{ authenticated: boolean; store:
refreshToken: localStorage.getItem(REFRESH_TOKEN) ?? '', refreshToken: localStorage.getItem(REFRESH_TOKEN) ?? '',
store, store,
}); });
touchShopSession();
return { authenticated: true, store }; return { authenticated: true, store };
} catch (e) { } catch (e) {
const err = e as Error & { status?: number }; const err = e as Error & { status?: number };
@@ -163,8 +206,8 @@ export async function ensureSession(): Promise<{ authenticated: boolean; store:
if (refreshed?.store) { if (refreshed?.store) {
return { authenticated: true, store: refreshed.store }; return { authenticated: true, store: refreshed.store };
} }
clearAuth(); clearAuth({ keepProfile: true });
return { authenticated: false, store: null }; return { authenticated: false, store: getStoreProfile() };
} }
const cached = getStoreProfile(); const cached = getStoreProfile();
if (cached) return { authenticated: true, store: cached }; if (cached) return { authenticated: true, store: cached };
+41 -12
View File
@@ -1,7 +1,8 @@
import type { ClientRuntimeConfig, WechatLoginResult } from '@dukang/shared-types'; import type { ClientRuntimeConfig, WechatLoginResult } from '@dukang/shared-types';
import { isWxAuthorizeEnabled } from '@dukang/shared-types'; import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
import { isWechatEnv, weixinSdk } from './weixin'; import { isWechatEnv, weixinSdk } from './weixin';
import { request, saveAuth, type ShopSessionPayload } from './api'; import { request, saveWechatSession, type ShopSessionPayload } from './api';
export type ShopAccountProfile = { export type ShopAccountProfile = {
id: string; id: string;
@@ -51,20 +52,17 @@ export function sessionFromWechatLogin(result: WechatLoginResult): ShopSessionPa
}; };
} }
export function saveShopWechatAuth(result: WechatLoginResult): boolean { /** 处理微信登录/绑定结果,写入 7 天免登录 session */
export function handleShopWechatLoginResult(result: WechatLoginResult): ShopSessionPayload | null {
const session = sessionFromWechatLogin(result); const session = sessionFromWechatLogin(result);
if (!session) return false; if (!session) return null;
saveAuth(session); saveWechatSession(session);
return true; return session;
} }
export async function authorizeShopWechat(): Promise<WechatLoginResult | void> { /** @deprecated 使用 handleShopWechatLoginResult */
const config = await fetchClientConfig(); export function saveShopWechatAuth(result: WechatLoginResult): boolean {
if (!isWxAuthorizeEnabled(config)) return; return !!handleShopWechatLoginResult(result);
if (!isWechatEnv()) {
throw new Error('请在微信内打开以完成授权');
}
return weixinSdk.login();
} }
export async function handleShopWechatCallback(): Promise<WechatLoginResult | null> { export async function handleShopWechatCallback(): Promise<WechatLoginResult | null> {
@@ -73,3 +71,34 @@ export async function handleShopWechatCallback(): Promise<WechatLoginResult | nu
if (!isWxAuthorizeEnabled(config)) return null; if (!isWxAuthorizeEnabled(config)) return null;
return weixinSdk.handleOAuthCallback(); return weixinSdk.handleOAuthCallback();
} }
/**
* 微信一键登录(已绑定微信的门店账号免验证码)。
* 返回 session = 已登录;void = 已跳转授权页等待回调。
*/
export async function loginShopWithWechat(): Promise<ShopSessionPayload | null | void> {
const config = await fetchClientConfig();
if (!isWxAuthorizeEnabled(config)) return null;
if (!isWechatEnv()) {
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
}
const result = await weixinSdk.login();
if (result) return handleShopWechatLoginResult(result);
}
/** 短信登录成功后于微信内自动发起 OAuth,绑定 openId 便于后续免登 */
export async function bindShopWechatAfterSmsLogin(): Promise<void> {
const config = await fetchClientConfig();
if (!isWxAuthorizeEnabled(config)) return;
if (!isWechatEnv()) return;
await weixinSdk.login();
}
export async function authorizeShopWechat(): Promise<WechatLoginResult | void> {
const config = await fetchClientConfig();
if (!isWxAuthorizeEnabled(config)) return;
if (!isWechatEnv()) {
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
}
return weixinSdk.login();
}
+2 -4
View File
@@ -8,8 +8,7 @@ import {
checkNeedsWechatAuth, checkNeedsWechatAuth,
fetchShopAccount, fetchShopAccount,
handleShopWechatCallback, handleShopWechatCallback,
saveShopWechatAuth, handleShopWechatLoginResult,
sessionFromWechatLogin,
} from '../lib/wechat-auth'; } from '../lib/wechat-auth';
import { isWechatEnv, weixinSdk } from '../lib/weixin'; import { isWechatEnv, weixinSdk } from '../lib/weixin';
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk'; import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
@@ -56,9 +55,8 @@ export default function HomePage() {
void handleShopWechatCallback() void handleShopWechatCallback()
.then((result) => { .then((result) => {
if (!result) return; if (!result) return;
const session = sessionFromWechatLogin(result); const session = handleShopWechatLoginResult(result);
if (session) { if (session) {
saveShopWechatAuth(result);
applySession(session); applySession(session);
} }
setAuthModalOpen(false); setAuthModalOpen(false);
+94 -24
View File
@@ -2,25 +2,42 @@ import { useEffect, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import AppImage from '@dukang/shared-ui/AppImage'; import AppImage from '@dukang/shared-ui/AppImage';
import { isWxAuthorizeEnabled } from '@dukang/shared-types'; import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
import { useStoreSession } from '../contexts/StoreSessionContext'; import { useStoreSession } from '../contexts/StoreSessionContext';
import { getLastPhone, getStoreProfile, request, type ShopSessionPayload } from '../lib/api'; import { getLastPhone, getStoreProfile, hasShopWxSession, request, saveAuth, type ShopSessionPayload } from '../lib/api';
import { fetchClientConfig } from '../lib/wechat-auth'; import {
bindShopWechatAfterSmsLogin,
fetchClientConfig,
handleShopWechatCallback,
handleShopWechatLoginResult,
loginShopWithWechat,
} from '../lib/wechat-auth';
import { isWechatEnv } from '../lib/weixin';
function maskPhone(phone: string) { function maskPhone(phone: string) {
if (phone.length < 7) return phone; if (phone.length < 7) return phone;
return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`; return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`;
} }
function formatWechatError(e: unknown): string {
const text = e instanceof Error ? e.message : '微信登录失败';
if (text.includes('首次登录') || text.includes('手机验证码')) {
return '该微信尚未绑定门店账号,请先使用手机验证码登录,登录后将自动关联微信';
}
return text;
}
export default function LoginPage() { export default function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { applySession } = useStoreSession(); const { applySession } = useStoreSession();
const [params] = useSearchParams(); const [params, setSearchParams] = useSearchParams();
const quick = params.get('quick') === '1'; const quick = params.get('quick') === '1';
const savedProfile = getStoreProfile(); const savedProfile = getStoreProfile();
const [phone, setPhone] = useState(getLastPhone()); const [phone, setPhone] = useState(getLastPhone());
const [code, setCode] = useState(''); const [code, setCode] = useState('');
const [agreed, setAgreed] = useState(true); const [agreed, setAgreed] = useState(true);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [wxLoading, setWxLoading] = useState(false);
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
const [codeCooldown, setCodeCooldown] = useState(0); const [codeCooldown, setCodeCooldown] = useState(0);
const [wxAuthorize, setWxAuthorize] = useState(false); const [wxAuthorize, setWxAuthorize] = useState(false);
@@ -31,6 +48,22 @@ export default function LoginPage() {
.catch(() => setWxAuthorize(false)); .catch(() => setWxAuthorize(false));
}, []); }, []);
useEffect(() => {
if (!isWechatEnv() || !wxAuthorize || !params.get('code')) return;
void handleShopWechatCallback()
.then((result) => {
if (!result) return;
const session = handleShopWechatLoginResult(result);
if (session) {
applySession(session);
stripOAuthParamsFromLocation();
setSearchParams({}, { replace: true });
navigate('/');
}
})
.catch((e) => setMsg(formatWechatError(e)));
}, [applySession, navigate, params, setSearchParams, wxAuthorize]);
const quickStoreName = savedProfile?.storeName ?? '门店管理中心'; const quickStoreName = savedProfile?.storeName ?? '门店管理中心';
const quickPhone = savedProfile?.phone || phone; const quickPhone = savedProfile?.phone || phone;
@@ -66,22 +99,22 @@ export default function LoginPage() {
} }
} }
async function login(options?: { quick?: boolean }) { async function login() {
if (!options?.quick && !ensureAgreed()) return; if (!ensureAgreed()) return;
setLoading(true); setLoading(true);
setMsg(''); setMsg('');
try { try {
if (options?.quick) {
await request('SHOP_H5', '/shop/auth/sms/send', {
method: 'POST',
body: JSON.stringify({ phone: quickPhone, scene: 'STORE_LOGIN' }),
});
}
const data = await request<ShopSessionPayload>('SHOP_H5', '/shop/auth/login/sms', { const data = await request<ShopSessionPayload>('SHOP_H5', '/shop/auth/login/sms', {
method: 'POST', method: 'POST',
body: JSON.stringify({ phone: options?.quick ? quickPhone : phone, code }), body: JSON.stringify({ phone, code }),
}); });
saveAuth(data);
applySession(data); applySession(data);
if (isWechatEnv() && wxAuthorize) {
setMsg('登录成功,正在关联微信…');
await bindShopWechatAfterSmsLogin();
return;
}
navigate('/'); navigate('/');
} catch (e) { } catch (e) {
setMsg(e instanceof Error ? e.message : '登录失败'); setMsg(e instanceof Error ? e.message : '登录失败');
@@ -90,13 +123,30 @@ export default function LoginPage() {
} }
} }
function wechatLogin() { async function wechatLogin() {
if (!ensureAgreed()) return; if (!ensureAgreed()) return;
if (!wxAuthorize) return; setMsg('');
setMsg('preV1:微信一键授权暂未开放,请使用手机验证码登录'); if (!isWechatEnv()) {
setMsg('请在微信内打开以使用微信一键登录');
return;
}
setWxLoading(true);
try {
const session = await loginShopWithWechat();
if (session) {
applySession(session);
navigate('/');
}
} catch (e) {
setMsg(formatWechatError(e));
} finally {
setWxLoading(false);
}
} }
if (quick) { if (quick) {
const canWechatQuick = wxAuthorize && isWechatEnv() && hasShopWxSession() && !!savedProfile;
return ( return (
<div className="shop-quick-login-page"> <div className="shop-quick-login-page">
<header className="shop-quick-header"> <header className="shop-quick-header">
@@ -129,18 +179,31 @@ export default function LoginPage() {
<div className="shop-quick-actions"> <div className="shop-quick-actions">
{msg && <p className="shop-login-msg">{msg}</p>} {msg && <p className="shop-login-msg">{msg}</p>}
{canWechatQuick ? (
<button <button
type="button" type="button"
className="shop-quick-login-btn" className="shop-quick-login-btn shop-quick-login-btn--wechat"
disabled={loading} disabled={wxLoading}
onClick={() => void login({ quick: true })} onClick={() => void wechatLogin()}
> >
<span>{loading ? '登录中...' : '一键登录'}</span> <span className="material-symbols-outlined">chat</span>
{!loading && <span className="material-symbols-outlined">arrow_forward</span>} <span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
</button> </button>
) : (
<p className="shop-login-msg" style={{ textAlign: 'center' }}>
{wxAuthorize && !isWechatEnv()
? '请在微信内打开以使用一键登录'
: '请使用验证码登录并绑定微信后,即可 7 天内免登录'}
</p>
)}
{!canWechatQuick && (
<Link to="/login" className="shop-quick-login-btn" style={{ textAlign: 'center', textDecoration: 'none' }}>
</Link>
)}
<div className="shop-quick-secure"> <div className="shop-quick-secure">
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>lock</span> <span className="material-symbols-outlined" style={{ fontSize: 16 }}>lock</span>
<span></span> <span>{canWechatQuick ? '微信验证 · 7 天内免登录' : '加密环境安全登录中'}</span>
</div> </div>
</div> </div>
@@ -224,9 +287,14 @@ export default function LoginPage() {
<span className="shop-login-divider-line" /> <span className="shop-login-divider-line" />
</div> </div>
<button type="button" className="shop-login-wechat" onClick={wechatLogin}> <button
type="button"
className="shop-login-wechat"
disabled={wxLoading}
onClick={() => void wechatLogin()}
>
<span className="material-symbols-outlined">chat</span> <span className="material-symbols-outlined">chat</span>
<span></span> <span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
</button> </button>
</> </>
)} )}
@@ -252,9 +320,11 @@ export default function LoginPage() {
<span className="material-symbols-outlined shop-fill-icon" style={{ opacity: 0.3, fontSize: 20, color: 'var(--color-outline)' }}> <span className="material-symbols-outlined shop-fill-icon" style={{ opacity: 0.3, fontSize: 20, color: 'var(--color-outline)' }}>
security security
</span> </span>
{hasShopWxSession() && savedProfile && (
<p style={{ marginTop: 16, textAlign: 'center' }}> <p style={{ marginTop: 16, textAlign: 'center' }}>
<Link to="/login?quick=1" className="text-primary body-md"></Link> <Link to="/login?quick=1" className="text-primary body-md"></Link>
</p> </p>
)}
</footer> </footer>
</div> </div>
); );
+5
View File
@@ -473,6 +473,11 @@
cursor: pointer; cursor: pointer;
} }
.shop-quick-login-btn--wechat {
background: #07c160;
box-shadow: 0 8px 24px rgba(7, 193, 96, 0.25);
}
.shop-quick-secure { .shop-quick-secure {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1393,7 +1393,8 @@ export class AuthService {
phoneVerified, phoneVerified,
}; };
const accessToken = this.jwtService.sign(payload); const accessToken = this.jwtService.sign(payload);
const refreshToken = this.jwtService.sign(payload, { expiresIn: '30d' }); const refreshExpiresIn = actorType === 'STORE' ? '7d' : '30d';
const refreshToken = this.jwtService.sign(payload, { expiresIn: refreshExpiresIn });
return { return {
accessToken, accessToken,
refreshToken, refreshToken,