From 12ba19ede4bc6c17a01537e6afb7c65bec143231 Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Thu, 9 Jul 2026 22:07:35 +0800 Subject: [PATCH] =?UTF-8?q?=E9=97=A8=E5=BA=97=E7=AB=AF=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E5=8F=B7=E8=87=AA=E5=8A=A8=E7=99=BB=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/h5-shop/src/components/AuthGate.tsx | 5 + apps/h5-shop/src/lib/api.ts | 53 ++++++- apps/h5-shop/src/lib/wechat-auth.ts | 53 +++++-- apps/h5-shop/src/pages/HomePage.tsx | 6 +- apps/h5-shop/src/pages/LoginPage.tsx | 144 +++++++++++++----- apps/h5-shop/src/styles.css | 5 + .../src/modules/iam/auth.service.ts | 3 +- 7 files changed, 210 insertions(+), 59 deletions(-) diff --git a/apps/h5-shop/src/components/AuthGate.tsx b/apps/h5-shop/src/components/AuthGate.tsx index 241df76..28f8cd4 100644 --- a/apps/h5-shop/src/components/AuthGate.tsx +++ b/apps/h5-shop/src/components/AuthGate.tsx @@ -1,4 +1,5 @@ import { Navigate, useLocation } from 'react-router-dom'; +import { getStoreProfile, hasShopWxSession } from '../lib/api'; import { useStoreSession } from '../contexts/StoreSessionContext'; 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)) { + const profile = getStoreProfile(); + if (profile && hasShopWxSession() && location.pathname !== '/login') { + return ; + } return ; } diff --git a/apps/h5-shop/src/lib/api.ts b/apps/h5-shop/src/lib/api.ts index d899d6d..5931cf8 100644 --- a/apps/h5-shop/src/lib/api.ts +++ b/apps/h5-shop/src/lib/api.ts @@ -28,8 +28,18 @@ 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'; -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() { 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) { localStorage.setItem(ACCESS_TOKEN, data.accessToken); 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(REFRESH_TOKEN); - localStorage.removeItem(STORE_PROFILE); + localStorage.removeItem(SESSION_EXPIRES_AT); + localStorage.removeItem(SHOP_WX_BOUND); + if (!options?.keepProfile) { + localStorage.removeItem(STORE_PROFILE); + localStorage.removeItem(LAST_PHONE); + } } export function isLoggedIn() { @@ -109,6 +146,7 @@ async function refreshSession(): Promise { null, ); saveAuth(data); + touchShopSession(); return data; } catch { return null; @@ -147,6 +185,10 @@ export async function ensureSession(): Promise<{ authenticated: boolean; store: if (!isLoggedIn()) { return { authenticated: false, store: null }; } + if (isShopSessionExpired()) { + clearAuth({ keepProfile: true }); + return { authenticated: false, store: getStoreProfile() }; + } try { const me = await rawRequest('/shop/auth/me'); const store = profileFromMe(me); @@ -155,6 +197,7 @@ export async function ensureSession(): Promise<{ authenticated: boolean; store: refreshToken: localStorage.getItem(REFRESH_TOKEN) ?? '', store, }); + touchShopSession(); return { authenticated: true, store }; } catch (e) { const err = e as Error & { status?: number }; @@ -163,8 +206,8 @@ export async function ensureSession(): Promise<{ authenticated: boolean; store: if (refreshed?.store) { return { authenticated: true, store: refreshed.store }; } - clearAuth(); - return { authenticated: false, store: null }; + clearAuth({ keepProfile: true }); + return { authenticated: false, store: getStoreProfile() }; } const cached = getStoreProfile(); if (cached) return { authenticated: true, store: cached }; diff --git a/apps/h5-shop/src/lib/wechat-auth.ts b/apps/h5-shop/src/lib/wechat-auth.ts index 4e55493..c6915f7 100644 --- a/apps/h5-shop/src/lib/wechat-auth.ts +++ b/apps/h5-shop/src/lib/wechat-auth.ts @@ -1,7 +1,8 @@ import type { ClientRuntimeConfig, WechatLoginResult } 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 { request, saveAuth, type ShopSessionPayload } from './api'; +import { request, saveWechatSession, type ShopSessionPayload } from './api'; export type ShopAccountProfile = { 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); - if (!session) return false; - saveAuth(session); - return true; + if (!session) return null; + saveWechatSession(session); + return session; } -export async function authorizeShopWechat(): Promise { - const config = await fetchClientConfig(); - if (!isWxAuthorizeEnabled(config)) return; - if (!isWechatEnv()) { - throw new Error('请在微信内打开以完成授权'); - } - return weixinSdk.login(); +/** @deprecated 使用 handleShopWechatLoginResult */ +export function saveShopWechatAuth(result: WechatLoginResult): boolean { + return !!handleShopWechatLoginResult(result); } export async function handleShopWechatCallback(): Promise { @@ -73,3 +71,34 @@ export async function handleShopWechatCallback(): Promise { + 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 { + const config = await fetchClientConfig(); + if (!isWxAuthorizeEnabled(config)) return; + if (!isWechatEnv()) return; + await weixinSdk.login(); +} + +export async function authorizeShopWechat(): Promise { + const config = await fetchClientConfig(); + if (!isWxAuthorizeEnabled(config)) return; + if (!isWechatEnv()) { + throw new Error(WECHAT_INAPP_REQUIRED_MSG); + } + return weixinSdk.login(); +} diff --git a/apps/h5-shop/src/pages/HomePage.tsx b/apps/h5-shop/src/pages/HomePage.tsx index 23940ab..64c28ec 100644 --- a/apps/h5-shop/src/pages/HomePage.tsx +++ b/apps/h5-shop/src/pages/HomePage.tsx @@ -8,8 +8,7 @@ import { checkNeedsWechatAuth, fetchShopAccount, handleShopWechatCallback, - saveShopWechatAuth, - sessionFromWechatLogin, + handleShopWechatLoginResult, } from '../lib/wechat-auth'; import { isWechatEnv, weixinSdk } from '../lib/weixin'; import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk'; @@ -56,9 +55,8 @@ export default function HomePage() { void handleShopWechatCallback() .then((result) => { if (!result) return; - const session = sessionFromWechatLogin(result); + const session = handleShopWechatLoginResult(result); if (session) { - saveShopWechatAuth(result); applySession(session); } setAuthModalOpen(false); diff --git a/apps/h5-shop/src/pages/LoginPage.tsx b/apps/h5-shop/src/pages/LoginPage.tsx index 10eb608..c54816a 100644 --- a/apps/h5-shop/src/pages/LoginPage.tsx +++ b/apps/h5-shop/src/pages/LoginPage.tsx @@ -2,25 +2,42 @@ import { useEffect, useState } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import AppImage from '@dukang/shared-ui/AppImage'; import { isWxAuthorizeEnabled } from '@dukang/shared-types'; +import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk'; import { useStoreSession } from '../contexts/StoreSessionContext'; -import { getLastPhone, getStoreProfile, request, type ShopSessionPayload } from '../lib/api'; -import { fetchClientConfig } from '../lib/wechat-auth'; +import { getLastPhone, getStoreProfile, hasShopWxSession, request, saveAuth, type ShopSessionPayload } from '../lib/api'; +import { + bindShopWechatAfterSmsLogin, + fetchClientConfig, + handleShopWechatCallback, + handleShopWechatLoginResult, + loginShopWithWechat, +} from '../lib/wechat-auth'; +import { isWechatEnv } from '../lib/weixin'; function maskPhone(phone: string) { if (phone.length < 7) return phone; 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() { const navigate = useNavigate(); const { applySession } = useStoreSession(); - const [params] = useSearchParams(); + const [params, setSearchParams] = useSearchParams(); const quick = params.get('quick') === '1'; const savedProfile = getStoreProfile(); const [phone, setPhone] = useState(getLastPhone()); const [code, setCode] = useState(''); const [agreed, setAgreed] = useState(true); const [loading, setLoading] = useState(false); + const [wxLoading, setWxLoading] = useState(false); const [msg, setMsg] = useState(''); const [codeCooldown, setCodeCooldown] = useState(0); const [wxAuthorize, setWxAuthorize] = useState(false); @@ -31,6 +48,22 @@ export default function LoginPage() { .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 quickPhone = savedProfile?.phone || phone; @@ -66,22 +99,22 @@ export default function LoginPage() { } } - async function login(options?: { quick?: boolean }) { - if (!options?.quick && !ensureAgreed()) return; + async function login() { + if (!ensureAgreed()) return; setLoading(true); setMsg(''); 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('SHOP_H5', '/shop/auth/login/sms', { method: 'POST', - body: JSON.stringify({ phone: options?.quick ? quickPhone : phone, code }), + body: JSON.stringify({ phone, code }), }); + saveAuth(data); applySession(data); + if (isWechatEnv() && wxAuthorize) { + setMsg('登录成功,正在关联微信…'); + await bindShopWechatAfterSmsLogin(); + return; + } navigate('/'); } catch (e) { setMsg(e instanceof Error ? e.message : '登录失败'); @@ -90,13 +123,30 @@ export default function LoginPage() { } } - function wechatLogin() { + async function wechatLogin() { if (!ensureAgreed()) return; - if (!wxAuthorize) return; - setMsg('preV1:微信一键授权暂未开放,请使用手机验证码登录'); + setMsg(''); + 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) { + const canWechatQuick = wxAuthorize && isWechatEnv() && hasShopWxSession() && !!savedProfile; + return (
@@ -129,18 +179,31 @@ export default function LoginPage() {
{msg &&

{msg}

} - + {canWechatQuick ? ( + + ) : ( +

+ {wxAuthorize && !isWechatEnv() + ? '请在微信内打开以使用一键登录' + : '请使用验证码登录并绑定微信后,即可 7 天内免登录'} +

+ )} + {!canWechatQuick && ( + + 验证码登录 + + )}
lock - 加密环境安全登录中 + {canWechatQuick ? '微信验证 · 7 天内免登录' : '加密环境安全登录中'}
@@ -218,16 +281,21 @@ export default function LoginPage() { {wxAuthorize && ( <> -
- - 或者 - -
+
+ + 或者 + +
- + )}
@@ -252,9 +320,11 @@ export default function LoginPage() { security -

- 一键登录 -

+ {hasShopWxSession() && savedProfile && ( +

+ 微信快捷登录 +

+ )} ); diff --git a/apps/h5-shop/src/styles.css b/apps/h5-shop/src/styles.css index e98f433..f4892c8 100644 --- a/apps/h5-shop/src/styles.css +++ b/apps/h5-shop/src/styles.css @@ -473,6 +473,11 @@ cursor: pointer; } +.shop-quick-login-btn--wechat { + background: #07c160; + box-shadow: 0 8px 24px rgba(7, 193, 96, 0.25); +} + .shop-quick-secure { display: flex; align-items: center; diff --git a/server/dukang-api/src/modules/iam/auth.service.ts b/server/dukang-api/src/modules/iam/auth.service.ts index b1ae06d..95e5d08 100644 --- a/server/dukang-api/src/modules/iam/auth.service.ts +++ b/server/dukang-api/src/modules/iam/auth.service.ts @@ -1393,7 +1393,8 @@ export class AuthService { phoneVerified, }; 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 { accessToken, refreshToken,