<!DOCTYPE html><html lang="zh" data-theme="dark"><head><meta charset="utf-8"><meta name="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1"><title>React + NestJS 购物平台练习【5】用户登录功能 · Cytrogen 的个人博客</title><meta name="description" content="本文是 React + NestJS 全栈购物平台实践的第五篇,专注于实现完整的用户登录功能。前端部分,教程详细讲解了如何使用 Formik 和 Yup 构建登录表单,并结合 React Query 和 Zustand 实现“记住我”功能与自动登录检查。后端部分,则深入实现了基于 JWT 的双令牌(Access Token + Refresh Token)认证机制,包括令牌的生成、验证、刷新和注销。本教程为构建一个安全、用户体验友好的全栈登录系统提供了从前端交互到后端认证的完整实战指南。"><link rel="icon" href="../favicon.png"><link rel="canonical" href="https://cytrogen.icu/posts/270a.html"><link rel="webmention" href="https://webmention.io/cytrogen.icu/webmention"><link rel="me" href="https://m.otter.homes/@Cytrogen"><link rel="me" href="https://github.com/cytrogen"><meta name="fediverse:creator" content="@Cytrogen@m.otter.homes"><link rel="preload" href="../fonts/opensans-regular-latin.woff2" as="font" type="font/woff2" crossorigin="anonymous"><style>@font-face {
font-family: 'Open Sans';
src: url('../fonts/opensans-regular-latin.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
size-adjust: 107%;
ascent-override: 97%;
descent-override: 25%;
line-gap-override: 0%;
}
</style><script>(function() {
try {
// 优先级:用户选择 > 系统偏好 > 默认浅色
const saved = localStorage.getItem('theme');
const theme = saved ||
(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.style.colorScheme = theme;
} catch (error) {
// 失败时使用默认主题,不阻塞渲染
document.documentElement.setAttribute('data-theme', 'light');
}
})();
</script><link rel="stylesheet" href="../css/ares.css"><script data-netlify-skip-bundle="true">(function() {
document.addEventListener('DOMContentLoaded', function() {
const theme = document.documentElement.getAttribute('data-theme');
const pageWrapper = document.getElementById('page-wrapper');
if (pageWrapper && theme) {
pageWrapper.setAttribute('data-theme', theme);
}
});
})();
</script><!-- hexo injector head_end start -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.12.0/dist/katex.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/hexo-math@4.0.0/dist/style.css">
<!-- hexo injector head_end end --><meta name="generator" content="Hexo 8.1.1"><link rel="alternate" href="atom.xml" title="Cytrogen 的个人博客" type="application/atom+xml">
</head><body><div id="page-wrapper"><a class="skip-link" href="#main-content">跳到主要内容</a><div class="wrap"><header><a class="logo-link" href="../index.html"><img src="../favicon.png" alt="logo"></a><div class="h-card visually-hidden"><img class="u-photo" src="https://cytrogen.icu/favicon.png" alt="Cytrogen"><a class="p-name u-url u-uid" href="https://cytrogen.icu">Cytrogen</a><p class="p-note">Cytrogen 的个人博客,Cytrogen's Blog</p><a class="u-url" rel="me noopener" target="_blank" href="https://m.otter.homes/@Cytrogen">Mastodon</a><a class="u-url" rel="me noopener" target="_blank" href="https://github.com/cytrogen">GitHub</a></div><nav class="site-nav"><div class="nav-main"><div class="nav-primary"><ul class="nav-list hidden-mobile"><li class="nav-item"><a class="nav-link" href="../index.html">首页</a></li></ul><div class="nav-tools"><div class="language-menu"><button class="language-toggle" type="button"><svg class="icon icon-globe" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" focusable="false"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm7.5-6.923c-.67.204-1.335.82-1.887 1.855A7.97 7.97 0 0 0 5.145 4H7.5V1.077zM4.09 4a9.267 9.267 0 0 1 .64-1.539 6.7 6.7 0 0 1 .597-.933A7.025 7.025 0 0 0 2.255 4H4.09zm-.582 3.5c.03-.877.138-1.718.312-2.5H1.674a6.958 6.958 0 0 0-.656 2.5h2.49zM4.847 5a12.5 12.5 0 0 0-.338 2.5H7.5V5H4.847zM8.5 5v2.5h2.99a12.495 12.495 0 0 0-.337-2.5H8.5zM4.51 8.5a12.5 12.5 0 0 0 .337 2.5H7.5V8.5H4.51zm3.99 0V11h2.653c.187-.765.306-1.608.338-2.5H8.5zM5.145 12c.138.386.295.744.468 1.068.552 1.035 1.218 1.65 1.887 1.855V12H5.145zm.182 2.472a6.696 6.696 0 0 1-.597-.933A9.268 9.268 0 0 1 4.09 12H2.255a7.024 7.024 0 0 0 3.072 2.472zM3.82 11a13.652 13.652 0 0 1-.312-2.5h-2.49c.062.89.291 1.733.656 2.5H3.82zm6.853 3.472A7.024 7.024 0 0 0 13.745 12H11.91a9.27 9.27 0 0 1-.64 1.539 6.688 6.688 0 0 1-.597.933zM8.5 12v2.923c.67-.204 1.335-.82 1.887-1.855A7.97 7.97 0 0 0 10.855 12H8.5zm3.68-1h2.146c.365-.767.594-1.61.656-2.5h-2.49a13.65 13.65 0 0 1-.312 2.5zm2.802-3.5a6.959 6.959 0 0 0-.656-2.5H12.18c.174.782.282 1.623.312 2.5h2.49zM11.27 2.461c.247.464.462.98.64 1.539h1.835a7.024 7.024 0 0 0-3.072-2.472c.218.284.418.598.597.933zM10.855 4a7.966 7.966 0 0 0-.468-1.068C9.835 1.897 9.17 1.282 8.5 1.077V4h2.355z"></path></svg><span>中文</span></button><div class="language-dropdown"></div></div></div><div class="nav-controls"><div class="more-menu hidden-mobile"><button class="more-toggle" type="button"><span>更多</span><svg class="icon icon-chevron-down" width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true" focusable="false"><path d="M6 8.825c-.2 0-.4-.1-.5-.2l-3.3-3.3c-.3-.3-.3-.8 0-1.1s.8-.3 1.1 0l2.7 2.7 2.7-2.7c.3-.3.8-.3 1.1 0s.3.8 0 1.1l-3.3 3.3c-.1.1-.3.2-.5.2z"></path></svg></button><div class="more-dropdown"><ul class="dropdown-list"><li class="dropdown-item"><a class="nav-link" href="../archives/index.html">归档</a></li><li class="dropdown-item"><a class="nav-link" href="../categories/index.html">分类</a></li><li class="dropdown-item"><a class="nav-link" href="../tags/index.html">标签</a></li><li class="dropdown-item"><a class="nav-link" href="../about/index.html">关于</a></li><li class="dropdown-item"><a class="nav-link" href="../sitemap/index.html">领地地图</a></li></ul></div></div><div class="theme-switcher"><button class="theme-toggle" type="button" role="switch" aria-pressed="false" aria-label="切换主题"><div class="theme-icon moon-icon"><svg class="icon icon-moon" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" focusable="false"><path d="M6 .278a.768.768 0 0 1 .08.858 7.208 7.208 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.787.787 0 0 1 .81.316.733.733 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.752.752 0 0 1 6 .278z"></path></svg></div><div class="theme-icon sun-icon"><svg class="icon icon-sun" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" focusable="false"><path d="M8 11a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0 1a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM8 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 0zm0 13a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 13zm8-5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2a.5.5 0 0 1 .5.5zM3 8a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2A.5.5 0 0 1 3 8zm10.657-5.657a.5.5 0 0 1 0 .707l-1.414 1.415a.5.5 0 1 1-.707-.708l1.414-1.414a.5.5 0 0 1 .707 0zm-9.193 9.193a.5.5 0 0 1 0 .707L3.05 13.657a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0zm9.193 2.121a.5.5 0 0 1-.707 0l-1.414-1.414a.5.5 0 0 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .707zM4.464 4.465a.5.5 0 0 1-.707 0L2.343 3.05a.5.5 0 1 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .708z"></path></svg></div></button></div><details class="mobile-menu-details hidden-desktop"><summary class="hamburger-menu" aria-label="nav.menu"><svg class="icon icon-bars" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" focusable="false"><path d="M2.5 12a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5z"></path></svg><span class="menu-text">nav.menu</span></summary><div class="mobile-menu-dropdown"><ul class="mobile-nav-list"><li class="mobile-nav-item"><a class="mobile-nav-link" href="../index.html">首页</a></li><li class="mobile-nav-item"><a class="mobile-nav-link" href="../archives/index.html">归档</a></li><li class="mobile-nav-item"><a class="mobile-nav-link" href="../categories/index.html">分类</a></li><li class="mobile-nav-item"><a class="mobile-nav-link" href="../tags/index.html">标签</a></li><li class="mobile-nav-item"><a class="mobile-nav-link" href="../about/index.html">关于</a></li><li class="mobile-nav-item"><a class="mobile-nav-link" href="../sitemap/index.html">领地地图</a></li></ul></div></details></div></div></div></nav></header><main class="container" id="main-content" tabindex="-1"><div class="post"><article class="post-block h-entry"><div class="post-meta p-author h-card visually-hidden"><img class="author-avatar u-photo" src="../favicon.png" alt="Cytrogen"><span class="p-name">Cytrogen</span><a class="u-url" href="https://cytrogen.icu">https://cytrogen.icu</a></div><a class="post-permalink u-url u-uid visually-hidden" href="https://cytrogen.icu/posts/270a.html">永久链接</a><div class="p-summary visually-hidden"><p>在构建电商平台的过程中,用户登录是一个不可或缺的核心功能。</p>
<p>本文将详细介绍如何在 React 前端实现登录表单组件,并结合 NestJS 后端完成完整的用户认证流程,包括 JWT 认证、记住我功能以及登录状态持久化等关键特性。</p></div><div class="visually-hidden"><a class="p-category" href="../categories/%E7%BC%96%E7%A8%8B%E7%AC%94%E8%AE%B0/">编程笔记</a><a class="p-category" href="../tags/%E5%85%A8%E6%A0%88%E5%AE%9E%E8%B7%B5/">全栈实践</a><a class="p-category" href="../tags/Node-js/">Node.js</a><a class="p-category" href="../tags/React-js/">React.js</a><a class="p-category" href="../tags/TypeScript/">TypeScript</a><a class="p-category" href="../tags/NestJS/">NestJS</a></div><h1 class="post-title p-name">React + NestJS 购物平台练习【5】用户登录功能</h1><div class="post-info"><time class="post-date dt-published" datetime="2025-02-12T05:00:00.000Z">2/12/2025</time><time class="dt-updated visually-hidden" datetime="2026-02-09T17:16:54.997Z"></time></div><div class="post-content e-content"><html><head></head><body><p>在构建电商平台的过程中,用户登录是一个不可或缺的核心功能。</p>
<p>本文将详细介绍如何在 React 前端实现登录表单组件,并结合 NestJS 后端完成完整的用户认证流程,包括 JWT 认证、记住我功能以及登录状态持久化等关键特性。</p>
<span id="more"></span>
<h1 id="1-小改动"><a class="markdownIt-Anchor" href="#1-小改动"></a> 1. 小改动</h1>
<h2 id="11-改变量字段名字"><a class="markdownIt-Anchor" href="#11-改变量字段名字"></a> 1.1. 改变量 / 字段名字</h2>
<p>将 React 项目的 <code>src/stores/user/types.ts</code> 中的 <code>LoginCredentials</code> 的 <code>email</code> 修改为 <code>username</code>:</p>
<figure class="highlight ts"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">export</span> <span class="keyword">interface</span> <span class="title class_">LoginCredentials</span> { </span><br><span class="line"> <span class="attr">username</span>: <span class="built_in">string</span>; </span><br><span class="line"> <span class="attr">password</span>: <span class="built_in">string</span>; </span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
<h2 id="12-修改依赖数组"><a class="markdownIt-Anchor" href="#12-修改依赖数组"></a> 1.2. 修改依赖数组</h2>
<p>在 <code>src/pages/VerifyEmail.tsx</code> 中的 <code>useEffect</code> 的依赖数组内加一个 <code>location.search</code>:</p>
<figure class="highlight tsx"><figcaption><span>VerifyEmail.tsx</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">useEffect</span>(<span class="function">() =></span> {</span><br><span class="line"> <span class="keyword">const</span> searchParams = <span class="keyword">new</span> <span class="title class_">URLSearchParams</span>(location.<span class="property">search</span>);</span><br><span class="line"> <span class="keyword">const</span> token = searchParams.<span class="title function_">get</span>(<span class="string">'token'</span>);</span><br><span class="line"> <span class="keyword">const</span> currentToken = useUserStore.<span class="title function_">getState</span>().<span class="property">verificationToken</span>;</span><br><span class="line"></span><br><span class="line"> <span class="keyword">if</span> (token && !verificationInProgress && !emailVerified && token !== currentToken) {</span><br><span class="line"> <span class="title function_">handleVerification</span>(token).<span class="title function_">then</span>(<span class="function"><span class="params">r</span> =></span> {</span><br><span class="line"> <span class="keyword">if</span> (r.<span class="property">success</span> && <span class="string">'message'</span> <span class="keyword">in</span> r) <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">'邮箱验证成功:'</span>, r.<span class="property">message</span>);</span><br><span class="line"> });</span><br><span class="line"> }</span><br><span class="line">}, [handleVerification, verificationInProgress, emailVerified, location.<span class="property">search</span>]);</span><br></pre></td></tr></tbody></table></figure>
<h2 id="13-新建-email-verificationinterfacets"><a class="markdownIt-Anchor" href="#13-新建-email-verificationinterfacets"></a> 1.3. 新建 <code>email-verification.interface.ts</code></h2>
<p>将原本放在 <code>users.service.ts</code> 内的这段内容单独放在一个文件里:</p>
<figure class="highlight ts"><figcaption><span>email-verification.interface.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">export</span> <span class="keyword">enum</span> <span class="title class_">EmailVerificationError</span> {</span><br><span class="line"> <span class="variable constant_">TOKEN_INVALID</span> = <span class="string">'TOKEN_INVALID'</span>,</span><br><span class="line"> <span class="variable constant_">TOKEN_EXPIRED</span> = <span class="string">'TOKEN_EXPIRED'</span>,</span><br><span class="line"> <span class="variable constant_">ALREADY_VERIFIED</span> = <span class="string">'ALREADY_VERIFIED'</span></span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">interface</span> <span class="title class_">VerificationResponse</span> {</span><br><span class="line"> <span class="attr">success</span>: <span class="built_in">boolean</span>;</span><br><span class="line"> <span class="attr">message</span>: <span class="built_in">string</span>;</span><br><span class="line"> <span class="attr">error</span>?: <span class="title class_">EmailVerificationError</span>;</span><br><span class="line"> <span class="attr">userId</span>?: <span class="built_in">string</span>;</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
<h2 id="14-统一查找用户的方法"><a class="markdownIt-Anchor" href="#14-统一查找用户的方法"></a> 1.4. 统一查找用户的方法</h2>
<p>原先我们只写了靠 ID 来查找用户的方法,如果要进行扩展的话,一个方法一个方法写太麻烦了,干脆写一个更灵活的查找方法、支持通过任何唯一字段查找用户:</p>
<figure class="highlight ts"><figcaption><span>users.service.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">async</span> <span class="title function_">findUser</span>(<span class="params"><span class="attr">criteria</span>: <span class="title class_">UserSearchCriteria</span></span>) {</span><br><span class="line"> <span class="variable language_">this</span>.<span class="property">logger</span>.<span class="title function_">debug</span>(<span class="string">`开始查找用户,条件:<span class="subst">${<span class="built_in">JSON</span>.stringify(criteria)}</span>`</span>);</span><br><span class="line"></span><br><span class="line"> <span class="keyword">const</span> whereClause = <span class="title class_">Object</span>.<span class="title function_">entries</span>(criteria)</span><br><span class="line"> .<span class="title function_">filter</span>(<span class="function">(<span class="params">[, value]</span>) =></span> value !== <span class="literal">undefined</span>)</span><br><span class="line"> .<span class="title function_">reduce</span>(<span class="function">(<span class="params">acc, [key, value]</span>) =></span> ({ ...acc, [key]: value }), {});</span><br><span class="line"></span><br><span class="line"> <span class="keyword">if</span> (<span class="title class_">Object</span>.<span class="title function_">keys</span>(whereClause).<span class="property">length</span> === <span class="number">0</span>) {</span><br><span class="line"> <span class="variable language_">this</span>.<span class="property">logger</span>.<span class="title function_">warn</span>(<span class="string">'查询条件为空'</span>);</span><br><span class="line"> <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">BadRequestException</span>(<span class="string">'至少需要一个查询条件'</span>);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">const</span> user = <span class="keyword">await</span> <span class="variable language_">this</span>.<span class="property">usersRepository</span>.<span class="title function_">findOne</span>({</span><br><span class="line"> <span class="attr">where</span>: whereClause</span><br><span class="line"> });</span><br><span class="line"></span><br><span class="line"> <span class="keyword">if</span> (!user) {</span><br><span class="line"> <span class="keyword">const</span> [[key, value]] = <span class="title class_">Object</span>.<span class="title function_">entries</span>(whereClause);</span><br><span class="line"> <span class="keyword">const</span> fieldMap = {</span><br><span class="line"> <span class="attr">id</span>: <span class="string">'ID'</span>,</span><br><span class="line"> <span class="attr">username</span>: <span class="string">'用户名'</span>,</span><br><span class="line"> <span class="attr">email</span>: <span class="string">'邮箱'</span>,</span><br><span class="line"> <span class="attr">verificationToken</span>: <span class="string">'验证令牌'</span></span><br><span class="line"> };</span><br><span class="line"> <span class="keyword">const</span> errorMessage = <span class="string">`未找到<span class="subst">${fieldMap[key]}</span>为 <span class="subst">${value}</span> 的用户`</span>;</span><br><span class="line"> <span class="variable language_">this</span>.<span class="property">logger</span>.<span class="title function_">warn</span>(<span class="string">`查找用户失败:<span class="subst">${errorMessage}</span>`</span>);</span><br><span class="line"> <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">NotFoundException</span>(errorMessage);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="variable language_">this</span>.<span class="property">logger</span>.<span class="title function_">debug</span>(<span class="string">`用户查找成功:<span class="subst">${user.id}</span>,用户详细信息:<span class="subst">${<span class="built_in">JSON</span>.stringify(user)}</span>`</span>);</span><br><span class="line"> <span class="keyword">return</span> user;</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
<figure class="highlight ts"><figcaption><span>users.interface.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">export</span> <span class="keyword">type</span> <span class="title class_">UserSearchCriteria</span> = <span class="title class_">Partial</span><{</span><br><span class="line"> <span class="attr">id</span>: <span class="built_in">string</span>;</span><br><span class="line"> <span class="attr">username</span>: <span class="built_in">string</span>;</span><br><span class="line"> <span class="attr">email</span>: <span class="built_in">string</span>;</span><br><span class="line"> <span class="attr">verificationToken</span>: <span class="built_in">string</span>;</span><br><span class="line">}>;</span><br></pre></td></tr></tbody></table></figure>
<p><code>UsersController</code> 的查找用户 API 也可以修改成类似的样子:</p>
<figure class="highlight ts"><figcaption><span>users.controller.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@ApiOperation</span>({ <span class="attr">summary</span>: <span class="string">'根据不同字段查找用户'</span> })</span><br><span class="line"><span class="meta">@ApiParam</span>({ <span class="attr">name</span>: <span class="string">'field'</span>, <span class="attr">enum</span>: [<span class="string">'id'</span>, <span class="string">'username'</span>, <span class="string">'email'</span>], <span class="attr">description</span>: <span class="string">'查找字段'</span> })</span><br><span class="line"><span class="meta">@ApiParam</span>({ <span class="attr">name</span>: <span class="string">'value'</span>, <span class="attr">description</span>: <span class="string">'查找值'</span> })</span><br><span class="line"><span class="meta">@ApiResponse</span>({ <span class="attr">status</span>: <span class="title class_">HttpStatus</span>.<span class="property">OK</span>, <span class="attr">description</span>: <span class="string">'获取用户信息成功'</span>, <span class="attr">type</span>: <span class="title class_">Users</span> })</span><br><span class="line"><span class="meta">@HttpCode</span>(<span class="title class_">HttpStatus</span>.<span class="property">OK</span>)</span><br><span class="line"><span class="meta">@Get</span>(<span class="string">':field/:value'</span>)</span><br><span class="line"><span class="keyword">async</span> <span class="title function_">findByField</span>(<span class="meta">@Param</span>(<span class="string">'field'</span>) <span class="attr">field</span>: <span class="string">'id'</span> | <span class="string">'username'</span> | <span class="string">'email'</span>, <span class="meta">@Param</span>(<span class="string">'value'</span>) <span class="attr">value</span>: <span class="built_in">string</span>): <span class="title class_">Promise</span><<span class="title class_">Users</span>> {</span><br><span class="line"> <span class="keyword">const</span> validFields = [<span class="string">'id'</span>, <span class="string">'username'</span>, <span class="string">'email'</span>];</span><br><span class="line"> <span class="keyword">if</span> (!validFields.<span class="title function_">includes</span>(field)) {</span><br><span class="line"> <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">BadRequestException</span>(<span class="string">`不支持通过 <span class="subst">${field}</span> 字段查找用户`</span>);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">return</span> <span class="variable language_">this</span>.<span class="property">usersService</span>.<span class="title function_">findUser</span>({ [field]: value });</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
<h2 id="15-jwt-payload-修复"><a class="markdownIt-Anchor" href="#15-jwt-payload-修复"></a> 1.5. <code>JWT Payload</code> 修复</h2>
<figure class="highlight ts"><figcaption><span>jwt-payload.interface.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 过去的接口</span></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">interface</span> <span class="title class_">JWTPayload</span> {</span><br><span class="line"> <span class="attr">sub</span>: <span class="built_in">number</span>; <span class="comment">// 错误的类型</span></span><br><span class="line"> <span class="attr">username</span>: <span class="built_in">string</span>;</span><br><span class="line"> <span class="attr">email</span>: <span class="built_in">string</span>;</span><br><span class="line"> <span class="attr">role</span>: <span class="built_in">string</span>;</span><br><span class="line"> <span class="attr">iat</span>?: <span class="built_in">number</span>;</span><br><span class="line"> <span class="attr">exp</span>?: <span class="built_in">number</span>;</span><br><span class="line"> <span class="attr">aud</span>?: <span class="built_in">string</span>;</span><br><span class="line"> <span class="attr">iss</span>?: <span class="built_in">string</span>;</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="comment">// 现在的接口</span></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">interface</span> <span class="title class_">JWTPayload</span> {</span><br><span class="line"> <span class="attr">sub</span>: <span class="built_in">string</span>; <span class="comment">// 修正为 string 类型,因为使用 UUID</span></span><br><span class="line"> <span class="attr">username</span>: <span class="built_in">string</span>;</span><br><span class="line"> <span class="attr">email</span>: <span class="built_in">string</span>;</span><br><span class="line"> <span class="attr">role</span>: <span class="built_in">string</span>;</span><br><span class="line"> <span class="attr">verified</span>: <span class="built_in">boolean</span>; <span class="comment">// 新增字段,用于邮箱验证状态</span></span><br><span class="line"> <span class="attr">iat</span>?: <span class="built_in">number</span>;</span><br><span class="line"> <span class="attr">exp</span>?: <span class="built_in">number</span>;</span><br><span class="line"> <span class="attr">aud</span>?: <span class="built_in">string</span>;</span><br><span class="line"> <span class="attr">iss</span>?: <span class="built_in">string</span>;</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
<h2 id="16-布局组件优化"><a class="markdownIt-Anchor" href="#16-布局组件优化"></a> 1.6. 布局组件优化</h2>
<p>为了更好地适应不同的页面布局需求,我们需要增加主布局组件的灵活性。原有的 <code>MainLayout</code> 组件只支持路由渲染:</p>
<figure class="highlight tsx"><figcaption><span>MainLayout.tsx</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> <span class="title function_">MainLayout</span> = (<span class="params"></span>) => {</span><br><span class="line"> <span class="keyword">return</span> (</span><br><span class="line"> <span class="language-xml"><span class="tag"><<span class="name">div</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">header</span> <span class="attr">className</span>=<span class="string">"bg-primary shadow"</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">nav</span> <span class="attr">className</span>=<span class="string">"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"</span>></span></span></span><br><span class="line"><span class="language-xml"> {/* TODO: 导航内容 */}</span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">nav</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">header</span>></span></span></span><br><span class="line"><span class="language-xml"></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">main</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">div</span> <span class="attr">className</span>=<span class="string">"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">Outlet</span> /></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">div</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">main</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">div</span>></span></span></span><br><span class="line"> );</span><br><span class="line">};</span><br></pre></td></tr></tbody></table></figure>
<p>我们对其进行了改造,使其同时支持直接的子组件渲染:</p>
<figure class="highlight tsx"><figcaption><span>MainLayout.tsx</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> <span class="title class_">React</span> <span class="keyword">from</span> <span class="string">'react'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">Outlet</span> } <span class="keyword">from</span> <span class="string">'react-router-dom'</span>;</span><br><span class="line"></span><br><span class="line"><span class="keyword">interface</span> <span class="title class_">MainLayoutProps</span> {</span><br><span class="line"> <span class="attr">children</span>?: <span class="title class_">React</span>.<span class="property">ReactNode</span>;</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> <span class="title function_">MainLayout</span> = (<span class="params">{ children }: <span class="title class_">MainLayoutProps</span></span>) => {</span><br><span class="line"> <span class="keyword">return</span> (</span><br><span class="line"> <span class="language-xml"><span class="tag"><<span class="name">div</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">header</span> <span class="attr">className</span>=<span class="string">"bg-primary shadow"</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">nav</span> <span class="attr">className</span>=<span class="string">"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"</span>></span></span></span><br><span class="line"><span class="language-xml"> {/* TODO: 导航内容 */}</span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">nav</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">header</span>></span></span></span><br><span class="line"><span class="language-xml"></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">main</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">div</span> <span class="attr">className</span>=<span class="string">"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"</span>></span></span></span><br><span class="line"><span class="language-xml"> { children ? children : <span class="tag"><<span class="name">Outlet</span> /></span> }</span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">div</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">main</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">div</span>></span></span></span><br><span class="line"> );</span><br><span class="line">};</span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">default</span> <span class="title class_">MainLayout</span>;</span><br></pre></td></tr></tbody></table></figure>
<h1 id="2-实现登录表单组件"><a class="markdownIt-Anchor" href="#2-实现登录表单组件"></a> 2. 实现登录表单组件</h1>
<p>在电商平台中,我们需要一个用户友好的登录界面,让用户能够:</p>
<ul>
<li>输入用户名和密码进行登录</li>
<li>在输入过程中获得适当的表单验证反馈</li>
<li>看到登录状态的加载提示</li>
<li>收到登陆成功或者失败的明确提示</li>
</ul>
<ol>
<li>
<p>让我们先在 <code>src/pages</code> 目录下创建 <code>Login.tsx</code>:</p>
<figure class="highlight tsx"><figcaption><span>Login.tsx</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> <span class="title class_">React</span> <span class="keyword">from</span> <span class="string">'react'</span>; </span><br><span class="line"><span class="keyword">import</span> { useFormik } <span class="keyword">from</span> <span class="string">'formik'</span>; </span><br><span class="line"><span class="keyword">import</span> * <span class="keyword">as</span> <span class="title class_">Yup</span> <span class="keyword">from</span> <span class="string">'yup'</span>; </span><br><span class="line"><span class="keyword">import</span> { useNavigate } <span class="keyword">from</span> <span class="string">'react-router-dom'</span>; </span><br><span class="line"><span class="keyword">import</span> { useUserStore } <span class="keyword">from</span> <span class="string">'../stores'</span>; </span><br><span class="line"><span class="keyword">import</span> <span class="title class_">AuthLayout</span> <span class="keyword">from</span> <span class="string">'../layouts/AuthLayout'</span>; </span><br><span class="line"><span class="keyword">import</span> logo <span class="keyword">from</span> <span class="string">'../assets/ShoppingNest.png'</span>; </span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> <span class="title function_">Login</span> = (<span class="params"></span>) => { </span><br><span class="line"> <span class="keyword">const</span> navigate = <span class="title function_">useNavigate</span>(); </span><br><span class="line"> <span class="keyword">const</span> login = <span class="title function_">useUserStore</span>(<span class="function"><span class="params">state</span> =></span> state.<span class="property">login</span>); </span><br><span class="line"> <span class="keyword">const</span> isLoading = <span class="title function_">useUserStore</span>(<span class="function"><span class="params">state</span> =></span> state.<span class="property">isLoading</span>); </span><br><span class="line"> <span class="keyword">const</span> error = <span class="title function_">useUserStore</span>(<span class="function"><span class="params">state</span> =></span> state.<span class="property">error</span>); </span><br><span class="line"></span><br><span class="line"> <span class="keyword">return</span> (</span><br><span class="line"> <span class="comment">// ...</span></span><br><span class="line"> )</span><br><span class="line">}</span><br><span class="line"><span class="keyword">export</span> <span class="keyword">default</span> <span class="title class_">Login</span>;</span><br></pre></td></tr></tbody></table></figure>
</li>
<li>
<p>使用 Formik 管理表单状态和处理提交:</p>
<figure class="highlight tsx"><figcaption><span>Login.tsx</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> formik = <span class="title function_">useFormik</span>({ </span><br><span class="line"> <span class="attr">initialValues</span>: { </span><br><span class="line"> <span class="attr">username</span>: <span class="string">''</span>, </span><br><span class="line"> <span class="attr">password</span>: <span class="string">''</span> </span><br><span class="line"> }, </span><br><span class="line"> validationSchema, </span><br><span class="line"> <span class="attr">onSubmit</span>: <span class="title function_">async</span> (values) => { </span><br><span class="line"> <span class="keyword">try</span> { </span><br><span class="line"> <span class="keyword">const</span> { username, password } = values; </span><br><span class="line"> <span class="keyword">await</span> <span class="title function_">login</span>({ username, password }); </span><br><span class="line"> <span class="title function_">navigate</span>(<span class="string">'/'</span>, { </span><br><span class="line"> <span class="attr">state</span>: { </span><br><span class="line"> <span class="attr">message</span>: <span class="string">'登陆成功!'</span> </span><br><span class="line"> } </span><br><span class="line"> }) </span><br><span class="line"> } <span class="keyword">catch</span> (error) { </span><br><span class="line"> <span class="variable language_">console</span>.<span class="title function_">error</span>(<span class="string">'登录失败:'</span>, error); </span><br><span class="line"> } </span><br><span class="line"> } </span><br><span class="line">}); </span><br></pre></td></tr></tbody></table></figure>
</li>
<li>
<p>使用 Yup 进行表单验证,确保用户名和密码不为空:</p>
<figure class="highlight tsx"><figcaption><span>Login.tsx</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> validationSchema = <span class="title class_">Yup</span>.<span class="title function_">object</span>().<span class="title function_">shape</span>({</span><br><span class="line"> <span class="attr">username</span>: <span class="title class_">Yup</span>.<span class="title function_">string</span>().<span class="title function_">required</span>(<span class="string">'请输入用户名!'</span>),</span><br><span class="line"> <span class="attr">password</span>: <span class="title class_">Yup</span>.<span class="title function_">string</span>().<span class="title function_">required</span>(<span class="string">'请输入密码!'</span>)</span><br><span class="line">});</span><br></pre></td></tr></tbody></table></figure>
</li>
<li>
<p>以下是页面的基本结构:</p>
<figure class="highlight tsx"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">return</span> ( </span><br><span class="line"> <span class="language-xml"><span class="tag"><<span class="name">AuthLayout</span>></span> </span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">img</span> <span class="attr">src</span>=<span class="string">{logo}</span> <span class="attr">className</span>=<span class="string">"w-1/4 mx-auto"</span> <span class="attr">alt</span>=<span class="string">"Shopping Nest的Logo"</span> /></span> </span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">h2</span> <span class="attr">className</span>=<span class="string">"text-2xl font-bold text-center m-6 text-neutral-content"</span>></span> </span></span><br><span class="line"><span class="language-xml"> 登录用户 </span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">h2</span>></span> </span></span><br><span class="line"><span class="language-xml"></span></span><br><span class="line"><span class="language-xml"> {error && ( </span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">div</span> <span class="attr">className</span>=<span class="string">"mb-4 p-3 text-sm text-error-content bg-error rounded-lg"</span>></span> </span></span><br><span class="line"><span class="language-xml"> {error} </span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">div</span>></span> </span></span><br><span class="line"><span class="language-xml"> )} </span></span><br><span class="line"><span class="language-xml"></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">form</span> <span class="attr">onSubmit</span>=<span class="string">{formik.handleSubmit}</span>></span> </span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">div</span> <span class="attr">className</span>=<span class="string">"mb-4"</span>></span> </span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">label</span> <span class="attr">className</span>=<span class="string">"block text-sm font-semibold text-neutral-content"</span> <span class="attr">htmlFor</span>=<span class="string">"username"</span>></span> </span></span><br><span class="line"><span class="language-xml"> 用户名 </span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">label</span>></span> </span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">input</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">type</span>=<span class="string">"text"</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">id</span>=<span class="string">"username"</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">name</span>=<span class="string">"username"</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">className</span>=<span class="string">{</span>`<span class="attr">w-full</span> <span class="attr">mt-2</span> <span class="attr">p-2</span> <span class="attr">border</span> <span class="attr">rounded-lg</span> <span class="attr">bg-base-300</span> ${ </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">formik.touched.username</span> && <span class="attr">formik.errors.username</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> ? "<span class="attr">border-error</span>" </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">:</span> "<span class="attr">border-neutral-600</span>" </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> } <span class="attr">text-base-content</span>`} </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">value</span>=<span class="string">{formik.values.username}</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">onChange</span>=<span class="string">{formik.handleChange}</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">onBlur</span>=<span class="string">{formik.handleBlur}</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">disabled</span>=<span class="string">{isLoading}</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> /></span> </span></span><br><span class="line"><span class="language-xml"> {formik.touched.username && formik.errors.username && ( </span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">p</span> <span class="attr">className</span>=<span class="string">"text-sm text-error mt-1"</span>></span>{formik.errors.username}<span class="tag"></<span class="name">p</span>></span> </span></span><br><span class="line"><span class="language-xml"> )} </span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">div</span>></span> </span></span><br><span class="line"><span class="language-xml"></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">div</span> <span class="attr">className</span>=<span class="string">"mb-4"</span>></span> </span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">label</span> <span class="attr">className</span>=<span class="string">"block text-sm font-semibold text-neutral-content"</span> <span class="attr">htmlFor</span>=<span class="string">"password"</span>></span> </span></span><br><span class="line"><span class="language-xml"> 密码 </span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">label</span>></span> </span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">input</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">type</span>=<span class="string">"password"</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">id</span>=<span class="string">"password"</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">name</span>=<span class="string">"password"</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">className</span>=<span class="string">{</span>`<span class="attr">w-full</span> <span class="attr">mt-2</span> <span class="attr">p-2</span> <span class="attr">border</span> <span class="attr">rounded-lg</span> <span class="attr">bg-base-300</span> ${ </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">formik.touched.password</span> && <span class="attr">formik.errors.password</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> ? "<span class="attr">border-error</span>" </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">:</span> "<span class="attr">border-neutral-600</span>" </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> } <span class="attr">text-base-content</span>`} </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">value</span>=<span class="string">{formik.values.password}</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">onChange</span>=<span class="string">{formik.handleChange}</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">onBlur</span>=<span class="string">{formik.handleBlur}</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">disabled</span>=<span class="string">{isLoading}</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> /></span> </span></span><br><span class="line"><span class="language-xml"> {formik.touched.password && formik.errors.password && ( </span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">p</span> <span class="attr">className</span>=<span class="string">"text-sm text-error mt-1"</span>></span>{formik.errors.password}<span class="tag"></<span class="name">p</span>></span> </span></span><br><span class="line"><span class="language-xml"> )} </span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">div</span>></span> </span></span><br><span class="line"><span class="language-xml"></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">button</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">type</span>=<span class="string">"submit"</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">className</span>=<span class="string">"w-full bg-primary text-primary-content py-2 rounded-lg mt-4 hover:brightness-90 disabled:opacity-50"</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">disabled</span>=<span class="string">{isLoading}</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> ></span> </span></span><br><span class="line"><span class="language-xml"> {isLoading ? "登录中…" : "登录"} </span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">button</span>></span> </span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">form</span>></span> </span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">AuthLayout</span>></span></span> </span><br><span class="line">) </span><br></pre></td></tr></tbody></table></figure>
</li>
<li>
<p>在 <code>src/router.tsx</code> 中添加 <code>/login</code> 路由:</p>
<figure class="highlight tsx"><figcaption><span>router.tsx</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> <span class="title class_">Login</span> <span class="keyword">from</span> <span class="string">'./pages/Login'</span>;</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> router = <span class="title function_">createBrowserRouter</span>([ </span><br><span class="line"> { </span><br><span class="line"> <span class="attr">path</span>: <span class="string">'/login'</span>, </span><br><span class="line"> <span class="attr">element</span>: <span class="language-xml"><span class="tag"><<span class="name">Login</span> /></span></span> </span><br><span class="line"> }</span><br><span class="line">]); </span><br></pre></td></tr></tbody></table></figure>
</li>
</ol>
<h1 id="3-配置-jwt-认证"><a class="markdownIt-Anchor" href="#3-配置-jwt-认证"></a> 3. 配置 JWT 认证</h1>
<p>在实现用户登录功能时,我们需要一个安全可靠的身份验证机制,以确保用户身份的真实性并保护系统的安全。</p>
<p>JWT(JSON Web Token)是一个开放标准,它提供了一种紧凑且自包含的方式,在各方之间安全地传输信息。</p>
<p>通过 JWT,我们可以生成安全的访问令牌、验证用户的身份和权限、保护需要认证的 API 端点,并在服务端对令牌的有效性进行校验。</p>
<p>要实现 JWT 认证机制,我们需要从多个方面入手:</p>
<ol>
<li>配置认证模块、JWT 模块的签名密钥以及相关选项</li>
<li>集成用户模块,以便进行用户身份验证</li>
<li>实现认证服务,该服务需要负责验证用户凭据、生成 JWT 令牌、处理令牌刷新以及检查令牌的有效性,确保认证流程的完整性和安全性</li>
<li>创建一个认证控制器,专门处理用户的登录请求,并返回认证结果和 JWT 令牌,确保前端能够正确接收和使用身份验证信息</li>
<li>实现 JWT 策略和守卫:从请求中提取 JWT 令牌,并验证其有效性,以此来保护系统中需要认证的路由,确保只有经过身份经验的用户才能访问受保护的资源</li>
</ol>
<h2 id="31-安装依赖"><a class="markdownIt-Anchor" href="#31-安装依赖"></a> 3.1. 安装依赖</h2>
<figure class="highlight bash"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">yarn add @nestjs/jwt @nestjs/passport passport passport-jwt</span><br><span class="line">yarn add -D @types/passport-jwt</span><br></pre></td></tr></tbody></table></figure>
<h2 id="32-认证模块"><a class="markdownIt-Anchor" href="#32-认证模块"></a> 3.2. 认证模块</h2>
<p>在 <code>src</code> 目录下创建 <code>auth</code> 目录,并创建 <code>auth.module.ts</code>:</p>
<figure class="highlight ts"><figcaption><span>auth.module.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> { <span class="title class_">Module</span> } <span class="keyword">from</span> <span class="string">'@nestjs/common'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">PassportModule</span> } <span class="keyword">from</span> <span class="string">'@nestjs/passport'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">JwtModule</span> } <span class="keyword">from</span> <span class="string">'@nestjs/jwt'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">ConfigService</span> } <span class="keyword">from</span> <span class="string">'@nestjs/config'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">AuthController</span> } <span class="keyword">from</span> <span class="string">'./auth.controller'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">AuthService</span> } <span class="keyword">from</span> <span class="string">'./auth.service'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">JwtStrategy</span> } <span class="keyword">from</span> <span class="string">'./jwt.strategy'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">UsersModule</span> } <span class="keyword">from</span> <span class="string">'../users/users.module'</span>;</span><br><span class="line"></span><br><span class="line"><span class="meta">@Module</span>({</span><br><span class="line"> <span class="attr">imports</span>: [</span><br><span class="line"> <span class="title class_">PassportModule</span>.<span class="title function_">register</span>({</span><br><span class="line"> <span class="attr">defaultStrategy</span>: <span class="string">'jwt'</span></span><br><span class="line"> }),</span><br><span class="line"> <span class="title class_">JwtModule</span>.<span class="title function_">registerAsync</span>({</span><br><span class="line"> <span class="attr">inject</span>: [<span class="title class_">ConfigService</span>],</span><br><span class="line"> <span class="attr">useFactory</span>: <span class="title function_">async</span> (<span class="attr">configService</span>: <span class="title class_">ConfigService</span>) => ({</span><br><span class="line"> <span class="attr">secret</span>: configService.<span class="property">get</span><<span class="built_in">string</span>>(<span class="string">'JWT_SECRET'</span>),</span><br><span class="line"> <span class="attr">signOptions</span>: {</span><br><span class="line"> <span class="attr">audience</span>: configService.<span class="property">get</span><<span class="built_in">string</span>>(<span class="string">'JWT_TOKEN_AUDIENCE'</span>),</span><br><span class="line"> <span class="attr">issuer</span>: configService.<span class="property">get</span><<span class="built_in">string</span>>(<span class="string">'JWT_TOKEN_ISSUER'</span>),</span><br><span class="line"> <span class="attr">expiresIn</span>: configService.<span class="property">get</span><<span class="built_in">number</span>>(<span class="string">'JWT_ACCESS_TOKEN_TTL'</span>)</span><br><span class="line"> }</span><br><span class="line"> })</span><br><span class="line"> }),</span><br><span class="line"> <span class="title class_">UsersModule</span></span><br><span class="line"> ],</span><br><span class="line"> <span class="attr">controllers</span>: [<span class="title class_">AuthController</span>],</span><br><span class="line"> <span class="attr">providers</span>: [<span class="title class_">AuthService</span>, <span class="title class_">JwtStrategy</span>],</span><br><span class="line"> <span class="attr">exports</span>: [<span class="title class_">AuthService</span>]</span><br><span class="line">})</span><br><span class="line"><span class="keyword">export</span> <span class="keyword">class</span> <span class="title class_">AuthModule</span> {}</span><br></pre></td></tr></tbody></table></figure>
<h2 id="33-用户验证和令牌生成"><a class="markdownIt-Anchor" href="#33-用户验证和令牌生成"></a> 3.3. 用户验证和令牌生成</h2>
<p>创建 <code>auth.service.ts</code>:</p>
<figure class="highlight ts"><figcaption><span>auth.service.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> { <span class="title class_">Injectable</span>, <span class="title class_">UnauthorizedException</span> } <span class="keyword">from</span> <span class="string">'@nestjs/common'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">JwtService</span> } <span class="keyword">from</span> <span class="string">'@nestjs/jwt'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">ConfigService</span> } <span class="keyword">from</span> <span class="string">'@nestjs/config'</span>;</span><br><span class="line"><span class="keyword">import</span> * <span class="keyword">as</span> bcrypt <span class="keyword">from</span> <span class="string">'bcrypt'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">UsersService</span> } <span class="keyword">from</span> <span class="string">'../users/users.service'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">JWTPayload</span> } <span class="keyword">from</span> <span class="string">'./interfaces/jwt-payload.interface'</span>;</span><br><span class="line"></span><br><span class="line"><span class="meta">@Injectable</span>()</span><br><span class="line"><span class="keyword">export</span> <span class="keyword">class</span> <span class="title class_">AuthService</span> {</span><br><span class="line"> <span class="title function_">constructor</span>(<span class="params"></span></span><br><span class="line"><span class="params"> <span class="keyword">private</span> <span class="attr">jwtService</span>: <span class="title class_">JwtService</span>,</span></span><br><span class="line"><span class="params"> <span class="keyword">private</span> <span class="attr">usersService</span>: <span class="title class_">UsersService</span>,</span></span><br><span class="line"><span class="params"> <span class="keyword">private</span> <span class="attr">configService</span>: <span class="title class_">ConfigService</span></span></span><br><span class="line"><span class="params"> </span>) {}</span><br><span class="line"></span><br><span class="line"> <span class="comment">// ...</span></span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
<ol>
<li>
<p>验证用户:</p>
<figure class="highlight ts"><figcaption><span>auth.service.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">async</span> <span class="title function_">validateUser</span>(<span class="params"><span class="attr">username</span>: <span class="built_in">string</span>, <span class="attr">password</span>: <span class="built_in">string</span></span>) {</span><br><span class="line"> <span class="keyword">const</span> user = <span class="keyword">await</span> <span class="variable language_">this</span>.<span class="property">usersService</span>.<span class="title function_">findUser</span>({ <span class="attr">username</span>: username });</span><br><span class="line"> <span class="keyword">if</span> (!user) {</span><br><span class="line"> <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">UnauthorizedException</span>(<span class="string">'用户名或密码错误'</span>);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">if</span> (!user.<span class="property">verified</span>) {</span><br><span class="line"> <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">UnauthorizedException</span>(<span class="string">'请先验证邮箱后再登录'</span>);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">const</span> isPasswordValid = <span class="keyword">await</span> bcrypt.<span class="title function_">compare</span>(password, user.<span class="property">password</span>);</span><br><span class="line"> <span class="keyword">if</span> (!isPasswordValid) {</span><br><span class="line"> <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">UnauthorizedException</span>(<span class="string">'用户名或密码错误'</span>);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">return</span> user;</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
</li>
<li>
<p>用户登录:</p>
<figure class="highlight ts"><figcaption><span>auth.service.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">async</span> <span class="title function_">login</span>(<span class="params"><span class="attr">user</span>: <span class="built_in">any</span></span>) {</span><br><span class="line"> <span class="keyword">const</span> <span class="attr">payload</span>: <span class="title class_">JWTPayload</span> = {</span><br><span class="line"> <span class="attr">sub</span>: user.<span class="property">sub</span>,</span><br><span class="line"> <span class="attr">username</span>: user.<span class="property">username</span>,</span><br><span class="line"> <span class="attr">email</span>: user.<span class="property">email</span>,</span><br><span class="line"> <span class="attr">role</span>: user.<span class="property">role</span></span><br><span class="line"> };</span><br></pre></td></tr></tbody></table></figure>
<p>首先构建 JWT 载荷。</p>
<blockquote>
<p>JWT 基本上由三个部分组成,使用 <code>.</code> 分割:</p>
<figure class="highlight plaintext"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">头.载荷.签名</span><br></pre></td></tr></tbody></table></figure>
<ol>
<li>
<p>头包含了令牌类型和使用的签名算法:</p>
<figure class="highlight json"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">{</span></span><br><span class="line"> <span class="attr">"alg"</span><span class="punctuation">:</span> <span class="string">"HS256"</span><span class="punctuation">,</span> <span class="comment">// 使用的算法</span></span><br><span class="line"> <span class="attr">"typ"</span><span class="punctuation">:</span> <span class="string">"JWT"</span></span><br><span class="line"><span class="punctuation">}</span></span><br></pre></td></tr></tbody></table></figure>
</li>
<li>
<p>载荷包含了我们存储的实际数据:</p>
<figure class="highlight json"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">{</span></span><br><span class="line"> <span class="attr">"sub"</span><span class="punctuation">:</span> <span class="string">"1234567890"</span><span class="punctuation">,</span> <span class="comment">// 用户 ID</span></span><br><span class="line"> <span class="attr">"username"</span><span class="punctuation">:</span> <span class="string">"john_doe"</span><span class="punctuation">,</span> <span class="comment">// 用户名</span></span><br><span class="line"> <span class="attr">"email"</span><span class="punctuation">:</span> <span class="string">"john@doe.com"</span><span class="punctuation">,</span> <span class="comment">// 邮箱</span></span><br><span class="line"> <span class="attr">"role"</span><span class="punctuation">:</span> <span class="string">"user"</span><span class="punctuation">,</span> <span class="comment">// 角色</span></span><br><span class="line"> <span class="attr">"iat"</span><span class="punctuation">:</span> <span class="number">1516239022</span><span class="punctuation">,</span> <span class="comment">// 签发时间</span></span><br><span class="line"> <span class="attr">"exp"</span><span class="punctuation">:</span> <span class="number">1516242622</span> <span class="comment">// 过期时间</span></span><br><span class="line"><span class="punctuation">}</span></span><br></pre></td></tr></tbody></table></figure>
</li>
<li>
<p>签名则使用密钥、对头和载荷进行签名:</p>
<figure class="highlight javascript"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="title class_">HMACSHA256</span>(</span><br><span class="line"> <span class="title function_">base64UrlEncode</span>(header) + <span class="string">"."</span> +</span><br><span class="line"> <span class="title function_">base64UrlEncode</span>(payload),</span><br><span class="line"> secret </span><br><span class="line">)</span><br></pre></td></tr></tbody></table></figure>
</li>
</ol>
</blockquote>
<figure class="highlight ts"><figcaption><span>auth.service.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> accessToken = <span class="variable language_">this</span>.<span class="property">jwtService</span>.<span class="title function_">sign</span>(payload, {</span><br><span class="line"> <span class="comment">// 令牌接收方</span></span><br><span class="line"> <span class="attr">audience</span>: <span class="variable language_">this</span>.<span class="property">configService</span>.<span class="property">get</span><<span class="built_in">string</span>>(<span class="string">'JWT_TOKEN_AUDIENCE'</span>),</span><br><span class="line"> <span class="comment">// 令牌发行方</span></span><br><span class="line"> <span class="attr">issuer</span>: <span class="variable language_">this</span>.<span class="property">configService</span>.<span class="property">get</span><<span class="built_in">string</span>>(<span class="string">'JWT_TOKEN_ISSUER'</span>),</span><br><span class="line"> <span class="comment">// 令牌有效期</span></span><br><span class="line"> <span class="attr">expiresIn</span>: <span class="variable language_">this</span>.<span class="property">configService</span>.<span class="property">get</span><<span class="built_in">number</span>>(<span class="string">'JWT_ACCESS_TOKEN_TTL'</span>),</span><br><span class="line">});</span><br></pre></td></tr></tbody></table></figure>
<p>使用 <code>JwtService</code> 生成签名令牌。</p>
<figure class="highlight ts"><figcaption><span>auth.service.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"> <span class="keyword">return</span> {</span><br><span class="line"> <span class="attr">access_token</span>: accessToken,</span><br><span class="line"> <span class="attr">token_type</span>: <span class="string">'Bearer'</span>,</span><br><span class="line"> <span class="attr">expires_in</span>: <span class="variable language_">this</span>.<span class="property">configService</span>.<span class="property">get</span><<span class="built_in">number</span>>(<span class="string">'JWT_ACCESS_TOKEN_TTL'</span>),</span><br><span class="line"> <span class="attr">user</span>: {</span><br><span class="line"> <span class="attr">id</span>: user.<span class="property">id</span>,</span><br><span class="line"> <span class="attr">username</span>: user.<span class="property">username</span>,</span><br><span class="line"> <span class="attr">email</span>: user.<span class="property">email</span>,</span><br><span class="line"> <span class="attr">role</span>: user.<span class="property">role</span>,</span><br><span class="line"> <span class="attr">verified</span>: user.<span class="property">verified</span></span><br><span class="line"> }</span><br><span class="line"> };</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
<p>这里说一下令牌类型 <code>Bearer</code>,它是 OAuth 2.0 的标准组成部分。</p>
</li>
<li>
<p>在令牌过期前更新令牌:</p>
<figure class="highlight ts"><figcaption><span>auth.service.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">async</span> <span class="title function_">refreshToken</span>(<span class="params"><span class="attr">user</span>: <span class="built_in">any</span></span>) {</span><br><span class="line"> <span class="keyword">const</span> <span class="attr">payload</span>: <span class="title class_">JWTPayload</span> = {</span><br><span class="line"> <span class="attr">sub</span>: user.<span class="property">id</span>,</span><br><span class="line"> <span class="attr">username</span>: user.<span class="property">username</span>,</span><br><span class="line"> <span class="attr">email</span>: user.<span class="property">email</span>,</span><br><span class="line"> <span class="attr">role</span>: user.<span class="property">role</span></span><br><span class="line"> };</span><br><span class="line"></span><br><span class="line"> <span class="keyword">return</span> {</span><br><span class="line"> <span class="attr">access_token</span>: <span class="variable language_">this</span>.<span class="property">jwtService</span>.<span class="title function_">sign</span>(payload)</span><br><span class="line"> };</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
<p>当现有访问令牌即将过期时,服务器会生成一个新的访问令牌。</p>
<p>使用与原始登录相同的用户信息构建新的载荷,这样用户就不需要重新登陆,可以无缝继续使用系统。</p>
</li>
<li>
<p>验证令牌:</p>
<figure class="highlight ts"><figcaption><span>auth.service.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">verifyToken</span>(<span class="params"><span class="attr">token</span>: <span class="built_in">string</span></span>) {</span><br><span class="line"> <span class="keyword">try</span> {</span><br><span class="line"> <span class="keyword">return</span> <span class="variable language_">this</span>.<span class="property">jwtService</span>.<span class="title function_">verify</span>(token);</span><br><span class="line"> } <span class="keyword">catch</span> (error) {</span><br><span class="line"> <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">UnauthorizedException</span>(<span class="string">'无效的令牌,错误:'</span>, error);</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
<p>这个方法负责验证传入的 JWT 令牌的有效性。</p>
<p><code>JwtService.verify()</code> 方法会检查令牌是否:</p>
<ul>
<li>签名有效(未被篡改)</li>
<li>未过期</li>
<li>是由我们的系统签发的</li>
</ul>
</li>
</ol>
<p>在 <code>auth</code> 目录下创建 <code>interfaces/jwt-payload.interface.ts</code> 文件,定义 JWT 载荷的 TypeScript 接口:</p>
<figure class="highlight ts"><figcaption><span>jwt-payload.interface.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">export</span> <span class="keyword">interface</span> <span class="title class_">JWTPayload</span> {</span><br><span class="line"> <span class="attr">sub</span>: <span class="built_in">number</span>;</span><br><span class="line"> <span class="attr">username</span>: <span class="built_in">string</span>;</span><br><span class="line"> <span class="attr">email</span>: <span class="built_in">string</span>;</span><br><span class="line"> <span class="attr">role</span>: <span class="built_in">string</span>;</span><br><span class="line"> <span class="attr">iat</span>?: <span class="built_in">number</span>;</span><br><span class="line"> <span class="attr">exp</span>?: <span class="built_in">number</span>;</span><br><span class="line"> <span class="attr">aud</span>?: <span class="built_in">string</span>;</span><br><span class="line"> <span class="attr">iss</span>?: <span class="built_in">string</span>;</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
<h2 id="34-认证控制器"><a class="markdownIt-Anchor" href="#34-认证控制器"></a> 3.4. 认证控制器</h2>
<p>我们先来写一下登录所需要的 DTO。</p>
<ol>
<li>
<p>定义登录请求的数据结构:</p>
<figure class="highlight ts"><figcaption><span>login.dto.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> { <span class="title class_">ApiProperty</span> } <span class="keyword">from</span> <span class="string">'@nestjs/swagger'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">IsNotEmpty</span>, <span class="title class_">IsString</span>, <span class="title class_">MinLength</span> } <span class="keyword">from</span> <span class="string">'class-validator'</span>;</span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">class</span> <span class="title class_">LoginDto</span> {</span><br><span class="line"> <span class="meta">@ApiProperty</span>({ <span class="attr">example</span>: <span class="string">'johndoe'</span>, <span class="attr">description</span>: <span class="string">'用户名'</span> })</span><br><span class="line"> <span class="meta">@IsString</span>()</span><br><span class="line"> <span class="meta">@IsNotEmpty</span>({ <span class="attr">message</span>: <span class="string">'用户名不能为空'</span> })</span><br><span class="line"> <span class="attr">username</span>: <span class="built_in">string</span>;</span><br><span class="line"></span><br><span class="line"> <span class="meta">@ApiProperty</span>({ <span class="attr">example</span>: <span class="string">'password123'</span>, <span class="attr">description</span>: <span class="string">'密码'</span> })</span><br><span class="line"> <span class="meta">@IsString</span>()</span><br><span class="line"> <span class="meta">@IsNotEmpty</span>({ <span class="attr">message</span>: <span class="string">'密码不能为空'</span> })</span><br><span class="line"> <span class="meta">@MinLength</span>(<span class="number">6</span>, { <span class="attr">message</span>: <span class="string">'密码长度不能小于6位'</span> })</span><br><span class="line"> <span class="attr">password</span>: <span class="built_in">string</span>;</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
</li>
<li>
<p>登录响应 DTO:</p>
<figure class="highlight ts"><figcaption><span>login-response.dto.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> { <span class="title class_">ApiProperty</span> } <span class="keyword">from</span> <span class="string">'@nestjs/swagger'</span>;</span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">class</span> <span class="title class_">LoginResponseDto</span> {</span><br><span class="line"> <span class="meta">@ApiProperty</span>({ <span class="attr">description</span>: <span class="string">'访问令牌'</span> })</span><br><span class="line"> <span class="attr">access_token</span>: <span class="built_in">string</span>;</span><br><span class="line"></span><br><span class="line"> <span class="meta">@ApiProperty</span>({ <span class="attr">description</span>: <span class="string">'令牌类型'</span>, <span class="attr">example</span>: <span class="string">'Bearer'</span> })</span><br><span class="line"> <span class="attr">token_type</span>: <span class="built_in">string</span>;</span><br><span class="line"></span><br><span class="line"> <span class="meta">@ApiProperty</span>({ <span class="attr">description</span>: <span class="string">'过期时间(秒)'</span>, <span class="attr">example</span>: <span class="number">3600</span> })</span><br><span class="line"> <span class="attr">expires_in</span>: <span class="built_in">number</span>;</span><br><span class="line"></span><br><span class="line"> <span class="meta">@ApiProperty</span>({</span><br><span class="line"> <span class="attr">description</span>: <span class="string">'用户信息'</span>,</span><br><span class="line"> <span class="attr">example</span>: {</span><br><span class="line"> <span class="attr">id</span>: <span class="string">'1'</span>,</span><br><span class="line"> <span class="attr">username</span>: <span class="string">'johndoe'</span>,</span><br><span class="line"> <span class="attr">email</span>: <span class="string">'john@example.com'</span>,</span><br><span class="line"> <span class="attr">role</span>: <span class="string">'user'</span>,</span><br><span class="line"> <span class="attr">verified</span>: <span class="literal">true</span></span><br><span class="line"> }</span><br><span class="line"> })</span><br><span class="line"> <span class="attr">user</span>: {</span><br><span class="line"> <span class="attr">id</span>: <span class="built_in">string</span>;</span><br><span class="line"> <span class="attr">username</span>: <span class="built_in">string</span>;</span><br><span class="line"> <span class="attr">email</span>: <span class="built_in">string</span>;</span><br><span class="line"> <span class="attr">role</span>: <span class="built_in">string</span>;</span><br><span class="line"> <span class="attr">verified</span>: <span class="built_in">boolean</span>;</span><br><span class="line"> };</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
</li>
</ol>
<p>创建 <code>auth.controller.ts</code>:</p>
<figure class="highlight ts"><figcaption><span>auth.controller.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> { <span class="title class_">Controller</span>, <span class="title class_">Post</span>, <span class="title class_">Body</span>, <span class="title class_">HttpCode</span>, <span class="title class_">HttpStatus</span>, <span class="title class_">UnauthorizedException</span> } <span class="keyword">from</span> <span class="string">'@nestjs/common'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">ApiTags</span>, <span class="title class_">ApiOperation</span>, <span class="title class_">ApiResponse</span> } <span class="keyword">from</span> <span class="string">'@nestjs/swagger'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">AuthService</span> } <span class="keyword">from</span> <span class="string">'./auth.service'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">LoginDto</span> } <span class="keyword">from</span> <span class="string">'./dto/login.dto'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">LoginResponseDto</span> } <span class="keyword">from</span> <span class="string">'./dto/login-response.dto'</span>;</span><br><span class="line"><span class="keyword">import</span> winstonLogger <span class="keyword">from</span> <span class="string">'../loggers/winston.logger'</span>;</span><br><span class="line"></span><br><span class="line"><span class="meta">@ApiTags</span>(<span class="string">'认证'</span>)</span><br><span class="line"><span class="meta">@Controller</span>(<span class="string">'auth'</span>)</span><br><span class="line"><span class="keyword">export</span> <span class="keyword">class</span> <span class="title class_">AuthController</span> {</span><br><span class="line"> <span class="keyword">private</span> <span class="keyword">readonly</span> logger = winstonLogger;</span><br><span class="line"></span><br><span class="line"> <span class="title function_">constructor</span>(<span class="params"><span class="keyword">private</span> <span class="keyword">readonly</span> <span class="attr">authService</span>: <span class="title class_">AuthService</span></span>) {}</span><br><span class="line"></span><br><span class="line"> <span class="meta">@ApiOperation</span>({ <span class="attr">summary</span>: <span class="string">'用户登录'</span> })</span><br><span class="line"> <span class="meta">@ApiResponse</span>({</span><br><span class="line"> <span class="attr">status</span>: <span class="title class_">HttpStatus</span>.<span class="property">OK</span>,</span><br><span class="line"> <span class="attr">description</span>: <span class="string">'登录成功'</span>,</span><br><span class="line"> <span class="attr">type</span>: <span class="title class_">LoginResponseDto</span></span><br><span class="line"> })</span><br><span class="line"> <span class="meta">@ApiResponse</span>({</span><br><span class="line"> <span class="attr">status</span>: <span class="title class_">HttpStatus</span>.<span class="property">UNAUTHORIZED</span>,</span><br><span class="line"> <span class="attr">description</span>: <span class="string">'登录失败'</span></span><br><span class="line"> })</span><br><span class="line"> <span class="meta">@HttpCode</span>(<span class="title class_">HttpStatus</span>.<span class="property">OK</span>)</span><br><span class="line"> <span class="meta">@Post</span>(<span class="string">'login'</span>)</span><br><span class="line"> <span class="keyword">async</span> <span class="title function_">login</span>(<span class="meta">@Body</span>() <span class="attr">loginDto</span>: <span class="title class_">LoginDto</span>): <span class="title class_">Promise</span><<span class="title class_">LoginResponseDto</span>> {</span><br><span class="line"> <span class="keyword">try</span> {</span><br><span class="line"> <span class="keyword">const</span> user = <span class="keyword">await</span> <span class="variable language_">this</span>.<span class="property">authService</span>.<span class="title function_">validateUser</span>(loginDto.<span class="property">username</span>, loginDto.<span class="property">password</span>);</span><br><span class="line"></span><br><span class="line"> <span class="variable language_">this</span>.<span class="property">logger</span>.<span class="title function_">debug</span>(<span class="string">`用户 <span class="subst">${loginDto.username}</span> 验证通过,正在生成令牌`</span>);</span><br><span class="line"> <span class="keyword">const</span> loginResult = <span class="keyword">await</span> <span class="variable language_">this</span>.<span class="property">authService</span>.<span class="title function_">login</span>(user);</span><br><span class="line"> <span class="variable language_">this</span>.<span class="property">logger</span>.<span class="title function_">info</span>(<span class="string">`用户 <span class="subst">${loginDto.username}</span> 登录成功`</span>);</span><br><span class="line"> <span class="keyword">return</span> loginResult;</span><br><span class="line"> } <span class="keyword">catch</span> (error) {</span><br><span class="line"> <span class="variable language_">this</span>.<span class="property">logger</span>.<span class="title function_">error</span>(<span class="string">`用户 <span class="subst">${loginDto.username}</span> 登录失败: <span class="subst">${error.message}</span>`</span>);</span><br><span class="line"> <span class="keyword">throw</span> error;</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
<h2 id="35-策略实现"><a class="markdownIt-Anchor" href="#35-策略实现"></a> 3.5. 策略实现</h2>
<p><code>AuthService.validateUser()</code> 方法仅是用于登录时的用户验证,以及生成初始的 JWT 令牌。</p>
<p>那后续请求该由谁来验证呢?</p>
<blockquote>
<p>可以想象成有这么一个商场。</p>
<p>你在前台登记(登录),工作人员会验证你的身份(<code>AuthService.validateUser()</code>),验证成功后给了你一个特殊的通行证(JWT 令牌)。</p>
<p>你在商场里遛弯,发现有一个 VIP 区域(访问受保护的 API),门口站一保安。你说你想进去,保安说别急先看看你的通行证(JWT 令牌)。</p>
<p>保安需要看到的是:</p>
<ul>
<li>这个通行证是不是商场发的(JWT 签名验证)</li>
<li>通行证有没有过期(JWT 过期检查)</li>
<li>你的会员资格是否还有效(接下来要写的方法)</li>
</ul>
<p>JWT 策略实现就是这个保安。</p>
</blockquote>
<p>创建 <code>jwt.strategy.ts</code>:</p>
<figure class="highlight ts"><figcaption><span>jwt.strategy.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> { <span class="title class_">Injectable</span>, <span class="title class_">UnauthorizedException</span> } <span class="keyword">from</span> <span class="string">'@nestjs/common'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">PassportStrategy</span> } <span class="keyword">from</span> <span class="string">'@nestjs/passport'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">ConfigService</span> } <span class="keyword">from</span> <span class="string">'@nestjs/config'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">Strategy</span>, <span class="title class_">ExtractJwt</span> } <span class="keyword">from</span> <span class="string">'passport-jwt'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">UsersService</span> } <span class="keyword">from</span> <span class="string">'../users/users.service'</span>;</span><br><span class="line"></span><br><span class="line"><span class="meta">@Injectable</span>()</span><br><span class="line"><span class="keyword">export</span> <span class="keyword">class</span> <span class="title class_">JwtStrategy</span> <span class="keyword">extends</span> <span class="title class_ inherited__">PassportStrategy</span>(<span class="title class_">Strategy</span>) {</span><br><span class="line"> <span class="title function_">constructor</span>(<span class="params"></span></span><br><span class="line"><span class="params"> <span class="keyword">private</span> <span class="attr">configService</span>: <span class="title class_">ConfigService</span>,</span></span><br><span class="line"><span class="params"> <span class="keyword">private</span> <span class="attr">usersService</span>: <span class="title class_">UsersService</span></span></span><br><span class="line"><span class="params"> </span>) {</span><br><span class="line"> <span class="variable language_">super</span>({</span><br><span class="line"> <span class="attr">jwtFromRequest</span>: <span class="title class_">ExtractJwt</span>.<span class="title function_">fromAuthHeaderAsBearerToken</span>(),</span><br><span class="line"> <span class="attr">ignoreExpiration</span>: <span class="literal">false</span>,</span><br><span class="line"> <span class="attr">secretOrKey</span>: configService.<span class="property">get</span><<span class="built_in">string</span>>(<span class="string">'JWT_SECRET'</span>),</span><br><span class="line"> <span class="attr">audience</span>: configService.<span class="property">get</span><<span class="built_in">string</span>>(<span class="string">'JWT_TOKEN_AUDIENCE'</span>),</span><br><span class="line"> <span class="attr">issuer</span>: configService.<span class="property">get</span><<span class="built_in">string</span>>(<span class="string">'JWT_TOKEN_ISSUER'</span>)</span><br><span class="line"> });</span><br><span class="line"> }</span><br></pre></td></tr></tbody></table></figure>
<p>检查用户是否还是「有效会员」:</p>
<figure class="highlight ts"><figcaption><span>jwt.strategy.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"> <span class="keyword">async</span> <span class="title function_">validate</span>(<span class="params"><span class="attr">payload</span>: <span class="built_in">any</span></span>) {</span><br><span class="line"> <span class="keyword">const</span> user = <span class="keyword">await</span> <span class="variable language_">this</span>.<span class="property">usersService</span>.<span class="title function_">findUser</span>({ <span class="attr">id</span>: payload.<span class="property">sub</span> });</span><br><span class="line"> <span class="keyword">if</span> (!user) {</span><br><span class="line"> <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">UnauthorizedException</span>(<span class="string">'用户不存在'</span>);</span><br><span class="line"> }</span><br><span class="line"> <span class="keyword">if</span> (!user.<span class="property">verified</span>) {</span><br><span class="line"> <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">UnauthorizedException</span>(<span class="string">'用户未验证'</span>);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">return</span> {</span><br><span class="line"> <span class="attr">id</span>: user.<span class="property">id</span>,</span><br><span class="line"> <span class="attr">username</span>: user.<span class="property">username</span>,</span><br><span class="line"> <span class="attr">email</span>: user.<span class="property">email</span>,</span><br><span class="line"> <span class="attr">role</span>: user.<span class="property">role</span></span><br><span class="line"> };</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
<h2 id="36-jwt-守卫"><a class="markdownIt-Anchor" href="#36-jwt-守卫"></a> 3.6. JWT 守卫</h2>
<blockquote>
<p>先前的商场例子里,提到了「受保护的 API」。</p>
<p>那么要如何创建这么个「VIP 区域」呢?我们就需要用到 JWT 守卫。</p>
</blockquote>
<p>创建 <code>jwt-auth.guard.ts</code>:</p>
<figure class="highlight ts"><figcaption><span>jwt-auth.guard.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Injectable</span>()</span><br><span class="line"><span class="keyword">export</span> <span class="keyword">class</span> <span class="title class_">JwtAuthGuard</span> <span class="keyword">extends</span> <span class="title class_ inherited__">AuthGuard</span>(<span class="string">'jwt'</span>) {</span><br><span class="line"> <span class="title function_">handleRequest</span>(<span class="params"><span class="attr">error</span>: <span class="built_in">any</span>, <span class="attr">user</span>: <span class="built_in">any</span>, <span class="attr">info</span>: <span class="built_in">any</span></span>) {</span><br><span class="line"> <span class="keyword">if</span> (error || !user) {</span><br><span class="line"> <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">UnauthorizedException</span>(<span class="string">'验证失败'</span>);</span><br><span class="line"> }</span><br><span class="line"> <span class="keyword">return</span> user;</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
<p>守卫的工作流程:</p>
<ol>
<li>收到请求时,检查 <code>Authorization</code> 头部是否包含 JWT 令牌</li>
<li>如果没有令牌或令牌无效,直接拦截请求并返回 <code>401</code> 错误</li>
<li>如果令牌有效,让请求通过并继续处理</li>
</ol>
<p>使用方式也很简单:</p>
<figure class="highlight ts"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Controller</span>(<span class="string">'orders'</span>)</span><br><span class="line"><span class="keyword">export</span> <span class="keyword">class</span> <span class="title class_">OrdersController</span> {</span><br><span class="line"> <span class="title function_">constructor</span>(<span class="params"><span class="keyword">private</span> <span class="attr">ordersService</span>: <span class="title class_">OrdersService</span></span>) {}</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 无需登录即可访问的公开 API</span></span><br><span class="line"> <span class="comment">// 例如获取热门商品</span></span><br><span class="line"> <span class="meta">@Get</span>(<span class="string">'popular'</span>)</span><br><span class="line"> <span class="title function_">getPopularOrders</span>(<span class="params"></span>) {</span><br><span class="line"> <span class="keyword">return</span> <span class="variable language_">this</span>.<span class="property">ordersService</span>.<span class="title function_">getPopularOrders</span>();</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 需要登录才能访问的 API</span></span><br><span class="line"> <span class="comment">// 例如用户的订单</span></span><br><span class="line"> <span class="meta">@UseGuards</span>(<span class="title class_">JwtAuthGuard</span>)</span><br><span class="line"> <span class="meta">@Get</span>(<span class="string">'my-orders'</span>)</span><br><span class="line"> <span class="title function_">getMyOrders</span>(<span class="params"><span class="meta">@CurrentUser</span>() <span class="attr">user</span>: <span class="built_in">any</span></span>) {</span><br><span class="line"> <span class="keyword">return</span> <span class="variable language_">this</span>.<span class="property">ordersService</span>.<span class="title function_">getOrdersByUserId</span>(user.<span class="property">id</span>);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 用户下单</span></span><br><span class="line"> <span class="meta">@UseGuards</span>(<span class="title class_">JwtAuthGuard</span>)</span><br><span class="line"> <span class="meta">@Post</span>(<span class="string">'create'</span>)</span><br><span class="line"> <span class="title function_">createOrder</span>(<span class="params"><span class="meta">@CurrentUser</span>() <span class="attr">user</span>: <span class="built_in">any</span>, <span class="meta">@Body</span>() <span class="attr">orderData</span>: <span class="built_in">any</span></span>) {</span><br><span class="line"> <span class="keyword">return</span> <span class="variable language_">this</span>.<span class="property">ordersService</span>.<span class="title function_">createOrder</span>(user.<span class="property">id</span>, orderData);</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
<p>也可以直接保护控制器的所有路由:</p>
<figure class="highlight ts"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@UseGuards</span>(<span class="title class_">JwtAuthGuard</span>) <span class="comment">// 保护个人信息路由</span></span><br><span class="line"><span class="meta">@Controller</span>(<span class="string">'profile'</span>)</span><br><span class="line"><span class="keyword">export</span> <span class="keyword">class</span> <span class="title class_">ProfileController</span> {</span><br><span class="line"> <span class="meta">@Get</span>()</span><br><span class="line"> <span class="title function_">getProfile</span>(<span class="params"><span class="meta">@CurrentUser</span>() <span class="attr">user</span>: <span class="built_in">any</span></span>) {</span><br><span class="line"> <span class="keyword">return</span> user;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="meta">@Put</span>()</span><br><span class="line"> <span class="title function_">updateProfile</span>(<span class="params"><span class="meta">@CurrentUser</span>() <span class="attr">user</span>: <span class="built_in">any</span>, <span class="meta">@Body</span>() <span class="attr">updateData</span>: <span class="built_in">any</span></span>) {</span><br><span class="line"> <span class="keyword">return</span> <span class="variable language_">this</span>.<span class="property">profileService</span>.<span class="title function_">update</span>(user.<span class="property">id</span>, updateData);</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
<h2 id="37-用户装饰器"><a class="markdownIt-Anchor" href="#37-用户装饰器"></a> 3.7. 用户装饰器</h2>
<p>假设我们有一个购物车的 API,需要获取当前用户的购物车信息:</p>
<figure class="highlight ts"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Controller</span>(<span class="string">'cart'</span>)</span><br><span class="line"><span class="keyword">export</span> <span class="keyword">class</span> <span class="title class_">CartController</span> {</span><br><span class="line"> <span class="title function_">constructor</span>(<span class="params"><span class="keyword">private</span> <span class="attr">cartService</span>: <span class="title class_">CartService</span></span>) {}</span><br><span class="line"></span><br><span class="line"> <span class="meta">@UseGuards</span>(<span class="title class_">JwtAuthGuard</span>)</span><br><span class="line"> <span class="meta">@Get</span>(<span class="string">'my-cart'</span>)</span><br><span class="line"> <span class="keyword">async</span> <span class="title function_">getMyCart</span>(<span class="params"><span class="meta">@CurrentUser</span>() <span class="attr">user</span>: <span class="built_in">any</span></span>) {</span><br><span class="line"> <span class="comment">// user 对象包含了 JWT 令牌中的用户信息</span></span><br><span class="line"> <span class="keyword">return</span> <span class="variable language_">this</span>.<span class="property">cartService</span>.<span class="title function_">getCartByUserId</span>(user.<span class="property">id</span>);</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
<p>如果没有 <code>CurrentUser</code> 装饰器,我们需要:</p>
<figure class="highlight ts"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Get</span>(<span class="string">'my-cart'</span>)</span><br><span class="line"><span class="keyword">async</span> <span class="title function_">getMyCart</span>(<span class="params"><span class="meta">@Request</span>() <span class="attr">req</span>: <span class="built_in">any</span></span>) {</span><br><span class="line"> <span class="keyword">const</span> user = req.<span class="property">user</span>; <span class="comment">// 从 request 对象中手动获取用户信息</span></span><br><span class="line"> <span class="keyword">return</span> <span class="variable language_">this</span>.<span class="property">cartService</span>.<span class="title function_">getCartByUserId</span>(user.<span class="property">id</span>);</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
<p><code>CurrentUser</code> 装饰器的实现:</p>
<figure class="highlight ts"><figcaption><span>current-user.decorator.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> { createParamDecorator, <span class="title class_">ExecutionContext</span> } <span class="keyword">from</span> <span class="string">'@nestjs/common'</span>;</span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">const</span> <span class="title class_">CurrentUser</span> = <span class="title function_">createParamDecorator</span>(<span class="function">(<span class="params"><span class="attr">data</span>: <span class="built_in">unknown</span>, <span class="attr">ctx</span>: <span class="title class_">ExecutionContext</span></span>) =></span> {</span><br><span class="line"> <span class="keyword">const</span> request = ctx.<span class="title function_">switchToHttp</span>().<span class="title function_">getRequest</span>();</span><br><span class="line"> <span class="keyword">return</span> request.<span class="property">user</span>;</span><br><span class="line">});</span><br></pre></td></tr></tbody></table></figure>
<p>它的好处在于:</p>
<ul>
<li>简化代码:不需要手动从 <code>request</code> 中提取用户信息</li>
<li>类型安全:可以明确知道返回的是用户对象</li>
<li>可重用性:在任何需要当前用户信息的地方都可以使用</li>
<li>关注点分离:控制器方法只需要关心用户信息,不需要关心它是如何获取的</li>
</ul>
<p>像是还可以有这样的使用场景:</p>
<figure class="highlight ts"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Controller</span>(<span class="string">'users'</span>)</span><br><span class="line"><span class="keyword">export</span> <span class="keyword">class</span> <span class="title class_">UsersController</span> {</span><br><span class="line"> <span class="meta">@UseGuards</span>(<span class="title class_">JwtAuthGuard</span>)</span><br><span class="line"> <span class="meta">@Get</span>(<span class="string">'profile'</span>)</span><br><span class="line"> <span class="title function_">getProfile</span>(<span class="params"><span class="meta">@CurrentUser</span>() <span class="attr">user</span>: <span class="built_in">any</span></span>) {</span><br><span class="line"> <span class="keyword">return</span> user; <span class="comment">// 直接返回用户信息</span></span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
<h1 id="4-添加记住我功能"><a class="markdownIt-Anchor" href="#4-添加记住我功能"></a> 4. 添加记住我功能</h1>
<p>在现代电商平台中,用户体验是至关重要的。用户希望能够快速、便捷地访问他们的账户,而不需要每次都重新输入用户名和密码。这就是「记住我」功能的用武之地。</p>
<p>通过「记住我」功能,用户可以选择在登录时保持一段时间的登录状态,即使关闭浏览器或重新打开应用,用户仍然可以自动登录,无需再次输入凭证。</p>
<h2 id="41-技术方案设计"><a class="markdownIt-Anchor" href="#41-技术方案设计"></a> 4.1. 技术方案设计</h2>
<p>我们采用 <code>RefreshToken</code> + <code>AccessToken</code> 的双令牌认证方案:</p>
<ul>
<li><code>AccessToken</code>:短期令牌,用于访问 API</li>
<li><code>RefreshToken</code>:长期令牌,用于刷新 <code>AccessToken</code></li>
</ul>
<p>这种方案的优势在于:</p>
<ul>
<li>安全性高:<code>AccessToken</code> 短期有效,即使泄露风险也较小</li>
<li>用户体验好:<code>RefreshToken</code> 可以静默刷新 <code>AccessToken</code>,用户无感知</li>
<li>可控性强:可以随时注销 <code>RefreshToken</code>,确保账户安全</li>
</ul>
<svg aria-roledescription="flowchart-v2" role="graphics-document document" viewBox="-8 -8 394.34765625 779.609375" style="max-width: 394.34765625px;" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="100%" id="mermaid-1771490033753"><style>#mermaid-1771490033753{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-1771490033753 .error-icon{fill:#552222;}#mermaid-1771490033753 .error-text{fill:#552222;stroke:#552222;}#mermaid-1771490033753 .edge-thickness-normal{stroke-width:2px;}#mermaid-1771490033753 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-1771490033753 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-1771490033753 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-1771490033753 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-1771490033753 .marker{fill:#333333;stroke:#333333;}#mermaid-1771490033753 .marker.cross{stroke:#333333;}#mermaid-1771490033753 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-1771490033753 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-1771490033753 .cluster-label text{fill:#333;}#mermaid-1771490033753 .cluster-label span,#mermaid-1771490033753 p{color:#333;}#mermaid-1771490033753 .label text,#mermaid-1771490033753 span,#mermaid-1771490033753 p{fill:#333;color:#333;}#mermaid-1771490033753 .node rect,#mermaid-1771490033753 .node circle,#mermaid-1771490033753 .node ellipse,#mermaid-1771490033753 .node polygon,#mermaid-1771490033753 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-1771490033753 .flowchart-label text{text-anchor:middle;}#mermaid-1771490033753 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-1771490033753 .node .label{text-align:center;}#mermaid-1771490033753 .node.clickable{cursor:pointer;}#mermaid-1771490033753 .arrowheadPath{fill:#333333;}#mermaid-1771490033753 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-1771490033753 .flowchart-link{stroke:#333333;fill:none;}#mermaid-1771490033753 .edgeLabel{background-color:#e8e8e8;text-align:center;}#mermaid-1771490033753 .edgeLabel rect{opacity:0.5;background-color:#e8e8e8;fill:#e8e8e8;}#mermaid-1771490033753 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-1771490033753 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-1771490033753 .cluster text{fill:#333;}#mermaid-1771490033753 .cluster span,#mermaid-1771490033753 p{color:#333;}#mermaid-1771490033753 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-1771490033753 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-1771490033753 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}</style><g><marker orient="auto" markerHeight="12" markerWidth="12" markerUnits="userSpaceOnUse" refY="5" refX="6" viewBox="0 0 10 10" class="marker flowchart" id="mermaid-1771490033753_flowchart-pointEnd"><path style="stroke-width: 1; stroke-dasharray: 1, 0;" class="arrowMarkerPath" d="M 0 0 L 10 5 L 0 10 z"></path></marker><marker orient="auto" markerHeight="12" markerWidth="12" markerUnits="userSpaceOnUse" refY="5" refX="4.5" viewBox="0 0 10 10" class="marker flowchart" id="mermaid-1771490033753_flowchart-pointStart"><path style="stroke-width: 1; stroke-dasharray: 1, 0;" class="arrowMarkerPath" d="M 0 5 L 10 10 L 10 0 z"></path></marker><marker orient="auto" markerHeight="11" markerWidth="11" markerUnits="userSpaceOnUse" refY="5" refX="11" viewBox="0 0 10 10" class="marker flowchart" id="mermaid-1771490033753_flowchart-circleEnd"><circle style="stroke-width: 1; stroke-dasharray: 1, 0;" class="arrowMarkerPath" r="5" cy="5" cx="5"></circle></marker><marker orient="auto" markerHeight="11" markerWidth="11" markerUnits="userSpaceOnUse" refY="5" refX="-1" viewBox="0 0 10 10" class="marker flowchart" id="mermaid-1771490033753_flowchart-circleStart"><circle style="stroke-width: 1; stroke-dasharray: 1, 0;" class="arrowMarkerPath" r="5" cy="5" cx="5"></circle></marker><marker orient="auto" markerHeight="11" markerWidth="11" markerUnits="userSpaceOnUse" refY="5.2" refX="12" viewBox="0 0 11 11" class="marker cross flowchart" id="mermaid-1771490033753_flowchart-crossEnd"><path style="stroke-width: 2; stroke-dasharray: 1, 0;" class="arrowMarkerPath" d="M 1,1 l 9,9 M 10,1 l -9,9"></path></marker><marker orient="auto" markerHeight="11" markerWidth="11" markerUnits="userSpaceOnUse" refY="5.2" refX="-1" viewBox="0 0 11 11" class="marker cross flowchart" id="mermaid-1771490033753_flowchart-crossStart"><path style="stroke-width: 2; stroke-dasharray: 1, 0;" class="arrowMarkerPath" d="M 1,1 l 9,9 M 10,1 l -9,9"></path></marker><g class="root"><g class="clusters"></g><g class="edgePaths"><path marker-end="url(#mermaid-1771490033753_flowchart-pointEnd)" style="fill:none;" class="edge-thickness-normal edge-pattern-solid flowchart-link LS-A LE-B" id="L-A-B-0" d="M165.852,33L165.852,37.167C165.852,41.333,165.852,49.667,165.918,57.2C165.984,64.734,166.116,71.467,166.182,74.834L166.248,78.201"></path><path marker-end="url(#mermaid-1771490033753_flowchart-pointEnd)" style="fill:none;" class="edge-thickness-normal edge-pattern-solid flowchart-link LS-B LE-C" id="L-B-C-0" d="M145.695,134.218L133.092,143.244C120.489,152.27,95.284,170.323,82.681,184.132C70.078,197.942,70.078,207.508,70.078,212.292L70.078,217.075"></path><path marker-end="url(#mermaid-1771490033753_flowchart-pointEnd)" style="fill:none;" class="edge-thickness-normal edge-pattern-solid flowchart-link LS-B LE-D" id="L-B-D-0" d="M187.008,134.218L199.445,143.244C211.881,152.27,236.753,170.323,249.189,184.132C261.625,197.942,261.625,207.508,261.625,212.292L261.625,217.075"></path><path marker-end="url(#mermaid-1771490033753_flowchart-pointEnd)" style="fill:none;" class="edge-thickness-normal edge-pattern-solid flowchart-link LS-C LE-E" id="L-C-E-0" d="M70.078,255.375L70.078,259.542C70.078,263.708,70.078,272.042,78.883,280.024C87.689,288.006,105.299,295.637,114.105,299.452L122.91,303.268"></path><path marker-end="url(#mermaid-1771490033753_flowchart-pointEnd)" style="fill:none;" class="edge-thickness-normal edge-pattern-solid flowchart-link LS-D LE-E" id="L-D-E-0" d="M261.625,255.375L261.625,259.542C261.625,263.708,261.625,272.042,252.82,280.024C244.014,288.006,226.404,295.637,217.599,299.452L208.793,303.268"></path><path marker-end="url(#mermaid-1771490033753_flowchart-pointEnd)" style="fill:none;" class="edge-thickness-normal edge-pattern-solid flowchart-link LS-E LE-F" id="L-E-F-0" d="M165.852,338.375L165.852,342.542C165.852,346.708,165.852,355.042,165.852,362.492C165.852,369.942,165.852,376.508,165.852,379.792L165.852,383.075"></path><path marker-end="url(#mermaid-1771490033753_flowchart-pointEnd)" style="fill:none;" class="edge-thickness-normal edge-pattern-solid flowchart-link LS-F LE-G" id="L-F-G-0" d="M165.852,421.375L165.852,425.542C165.852,429.708,165.852,438.042,165.918,445.575C165.984,453.109,166.116,459.842,166.182,463.209L166.248,466.576"></path><path marker-end="url(#mermaid-1771490033753_flowchart-pointEnd)" style="fill:none;" class="edge-thickness-normal edge-pattern-solid flowchart-link LS-G LE-H" id="L-G-H-0" d="M138.434,552.192L127.355,562.428C116.275,572.664,94.116,593.137,83.037,608.156C71.957,623.176,71.957,632.743,71.957,637.526L71.957,642.309"></path><path marker-end="url(#mermaid-1771490033753_flowchart-pointEnd)" style="fill:none;" class="edge-thickness-normal edge-pattern-solid flowchart-link LS-G LE-I" id="L-G-I-0" d="M194.269,552.192L205.182,562.428C216.095,572.664,237.92,593.137,248.833,608.156C259.746,623.176,259.746,632.743,259.746,637.526L259.746,642.309"></path><path marker-end="url(#mermaid-1771490033753_flowchart-pointEnd)" style="fill:none;" class="edge-thickness-normal edge-pattern-solid flowchart-link LS-I LE-J" id="L-I-J-0" d="M259.746,680.609L259.746,684.776C259.746,688.943,259.746,697.276,259.746,704.726C259.746,712.176,259.746,718.743,259.746,722.026L259.746,725.309"></path></g><g class="edgeLabels"><g class="edgeLabel"><g transform="translate(0, 0)" class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"></span></div></foreignObject></g></g><g transform="translate(70.078125, 188.375)" class="edgeLabel"><g transform="translate(-2.921875, -9)" class="label"><foreignObject height="18" width="5.84375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">是</span></div></foreignObject></g></g><g transform="translate(261.625, 188.375)" class="edgeLabel"><g transform="translate(-2.921875, -9)" class="label"><foreignObject height="18" width="5.84375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">否</span></div></foreignObject></g></g><g class="edgeLabel"><g transform="translate(0, 0)" class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"></span></div></foreignObject></g></g><g class="edgeLabel"><g transform="translate(0, 0)" class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"></span></div></foreignObject></g></g><g class="edgeLabel"><g transform="translate(0, 0)" class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"></span></div></foreignObject></g></g><g class="edgeLabel"><g transform="translate(0, 0)" class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"></span></div></foreignObject></g></g><g transform="translate(71.95703125, 613.609375)" class="edgeLabel"><g transform="translate(-5.84375, -9)" class="label"><foreignObject height="18" width="11.6875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">有效</span></div></foreignObject></g></g><g transform="translate(259.74609375, 613.609375)" class="edgeLabel"><g transform="translate(-5.84375, -9)" class="label"><foreignObject height="18" width="11.6875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">无效</span></div></foreignObject></g></g><g class="edgeLabel"><g transform="translate(0, 0)" class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"></span></div></foreignObject></g></g></g><g class="nodes"><g transform="translate(165.8515625, 16.5)" data-id="A" data-node="true" id="flowchart-A-0" class="node default default flowchart-label"><rect height="33" width="38.375" y="-16.5" x="-19.1875" ry="0" rx="0" style="" class="basic label-container"></rect><g transform="translate(-11.6875, -9)" style="" class="label"><rect></rect><foreignObject height="18" width="23.375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">用户登录</span></div></foreignObject></g></g><g transform="translate(165.8515625, 118.6875)" data-id="B" data-node="true" id="flowchart-B-1" class="node default default flowchart-label"><polygon style="" transform="translate(-35.6875,35.6875)" class="label-container" points="35.6875,0 71.375,-35.6875 35.6875,-71.375 0,-35.6875"></polygon><g transform="translate(-11.6875, -9)" style="" class="label"><rect></rect><foreignObject height="18" width="23.375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">记住我?</span></div></foreignObject></g></g><g transform="translate(70.078125, 238.875)" data-id="C" data-node="true" id="flowchart-C-3" class="node default default flowchart-label"><rect height="33" width="140.15625" y="-16.5" x="-70.078125" ry="0" rx="0" style="" class="basic label-container"></rect><g transform="translate(-62.578125, -9)" style="" class="label"><rect></rect><foreignObject height="18" width="125.15625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">生成 7 天 RefreshToken</span></div></foreignObject></g></g><g transform="translate(261.625, 238.875)" data-id="D" data-node="true" id="flowchart-D-5" class="node default default flowchart-label"><rect height="33" width="142.9375" y="-16.5" x="-71.46875" ry="0" rx="0" style="" class="basic label-container"></rect><g transform="translate(-63.96875, -9)" style="" class="label"><rect></rect><foreignObject height="18" width="127.9375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"> 生成会话级 RefreshToken</span></div></foreignObject></g></g><g transform="translate(165.8515625, 321.875)" data-id="E" data-node="true" id="flowchart-E-7" class="node default default flowchart-label"><rect height="33" width="229.015625" y="-16.5" x="-114.5078125" ry="0" rx="0" style="" class="basic label-container"></rect><g transform="translate(-107.0078125, -9)" style="" class="label"><rect></rect><foreignObject height="18" width="214.015625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"> 返回 AccessToken + RefreshToken</span></div></foreignObject></g></g><g transform="translate(165.8515625, 404.875)" data-id="F" data-node="true" id="flowchart-F-11" class="node default default flowchart-label"><rect height="33" width="158.4375" y="-16.5" x="-79.21875" ry="0" rx="0" style="" class="basic label-container"></rect><g transform="translate(-71.71875, -9)" style="" class="label"><rect></rect><foreignObject height="18" width="143.4375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"> 访问 API 携带 AccessToken</span></div></foreignObject></g></g><g transform="translate(165.8515625, 525.4921875)" data-id="G" data-node="true" id="flowchart-G-13" class="node default default flowchart-label"><polygon style="" transform="translate(-54.1171875,54.1171875)" class="label-container" points="54.1171875,0 108.234375,-54.1171875 54.1171875,-108.234375 0,-54.1171875"></polygon><g transform="translate(-30.1171875, -9)" style="" class="label"><rect></rect><foreignObject height="18" width="60.234375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">Token 有效?</span></div></foreignObject></g></g><g transform="translate(71.95703125, 664.109375)" data-id="H" data-node="true" id="flowchart-H-15" class="node default default flowchart-label"><rect height="33" width="38.375" y="-16.5" x="-19.1875" ry="0" rx="0" style="" class="basic label-container"></rect><g transform="translate(-11.6875, -9)" style="" class="label"><rect></rect><foreignObject height="18" width="23.375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">正常访问</span></div></foreignObject></g></g><g transform="translate(259.74609375, 664.109375)" data-id="I" data-node="true" id="flowchart-I-17" class="node default default flowchart-label"><rect height="33" width="237.203125" y="-16.5" x="-118.6015625" ry="0" rx="0" style="" class="basic label-container"></rect><g transform="translate(-111.1015625, -9)" style="" class="label"><rect></rect><foreignObject height="18" width="222.203125"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">使用 RefreshToken 获取新 AccessToken</span></div></foreignObject></g></g><g transform="translate(259.74609375, 747.109375)" data-id="J" data-node="true" id="flowchart-J-19" class="node default default flowchart-label"><rect height="33" width="75.859375" y="-16.5" x="-37.9296875" ry="0" rx="0" style="" class="basic label-container"></rect><g transform="translate(-30.4296875, -9)" style="" class="label"><rect></rect><foreignObject height="18" width="60.859375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"> 重新尝试 API 请求</span></div></foreignObject></g></g></g></g></g></svg>
<h2 id="42-环境配置"><a class="markdownIt-Anchor" href="#42-环境配置"></a> 4.2. 环境配置</h2>
<h4 id="421-修改环境变量"><a class="markdownIt-Anchor" href="#421-修改环境变量"></a> 4.2.1. 修改环境变量</h4>
<p>首先需要在 <code>.env</code> 文件中添加新的配置项:</p>
<figure class="highlight plaintext"><figcaption><span>.env</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"># JWT </span><br><span class="line">JWT_ACCESS_SECRET=access_secret </span><br><span class="line">JWT_REFRESH_SECRET=refresh_secret </span><br><span class="line">JWT_TOKEN_AUDIENCE=localhost:4000 </span><br><span class="line">JWT_TOKEN_ISSUER=localhost:4000 </span><br><span class="line">JWT_ACCESS_TOKEN_TTL=3600 </span><br><span class="line">JWT_REFRESH_TOKEN_TTL=604800</span><br></pre></td></tr></tbody></table></figure>
<p>配置说明:</p>
<ul>
<li><code>JWT_ACCESS_SECRET</code>:访问令牌密钥</li>
<li><code>JWT_REFRESH_SECRET</code>:刷新令牌密钥</li>
<li><code>JWT_ACCESS_TOKEN_TTL</code>:访问令牌有效期(1 小时)</li>
<li><code>JWT_REFRESH_TOKEN_TTL</code>:刷新令牌有效期(7 天)</li>
</ul>
<h4 id="422-更新配置验证"><a class="markdownIt-Anchor" href="#422-更新配置验证"></a> 4.2.2. 更新配置验证</h4>
<p>在 <code>AppModule</code> 中更新 <code>ConfigModule</code> 的配置验证:</p>
<figure class="highlight ts"><figcaption><span>app.module.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Module</span>({</span><br><span class="line"> <span class="attr">imports</span>: [</span><br><span class="line"> <span class="title class_">ConfigModule</span>.<span class="title function_">forRoot</span>({</span><br><span class="line"> <span class="attr">validationSchema</span>: <span class="title class_">Joi</span>.<span class="title function_">object</span>({</span><br><span class="line"> <span class="comment">// ... 其他配置项 ...</span></span><br><span class="line"> </span><br><span class="line"> <span class="comment">// JWT 配置</span></span><br><span class="line"> <span class="attr">JWT_ACCESS_SECRET</span>: <span class="title class_">Joi</span>.<span class="title function_">string</span>().<span class="title function_">required</span>(),</span><br><span class="line"> <span class="attr">JWT_REFRESH_SECRET</span>: <span class="title class_">Joi</span>.<span class="title function_">string</span>().<span class="title function_">required</span>(),</span><br><span class="line"> <span class="attr">JWT_TOKEN_AUDIENCE</span>: <span class="title class_">Joi</span>.<span class="title function_">string</span>().<span class="title function_">required</span>(),</span><br><span class="line"> <span class="attr">JWT_TOKEN_ISSUER</span>: <span class="title class_">Joi</span>.<span class="title function_">string</span>().<span class="title function_">required</span>(),</span><br><span class="line"> <span class="attr">JWT_ACCESS_TOKEN_TTL</span>: <span class="title class_">Joi</span>.<span class="title function_">number</span>().<span class="title function_">default</span>(<span class="number">3600</span>),</span><br><span class="line"> <span class="attr">JWT_REFRESH_TOKEN_TTL</span>: <span class="title class_">Joi</span>.<span class="title function_">number</span>().<span class="title function_">default</span>(<span class="number">604800</span>)</span><br><span class="line"> })</span><br><span class="line"> })</span><br><span class="line"> ]</span><br><span class="line">})</span><br></pre></td></tr></tbody></table></figure>
<h4 id="423-更新认证模块"><a class="markdownIt-Anchor" href="#423-更新认证模块"></a> 4.2.3. 更新认证模块</h4>
<p>在 <code>AuthModule</code> 中更新 <code>JwtModule</code> 的配置:</p>
<figure class="highlight ts"><figcaption><span>app.module.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Module</span>({</span><br><span class="line"> <span class="attr">imports</span>: [</span><br><span class="line"> <span class="title class_">JwtModule</span>.<span class="title function_">registerAsync</span>({</span><br><span class="line"> <span class="attr">inject</span>: [<span class="title class_">ConfigService</span>],</span><br><span class="line"> <span class="attr">useFactory</span>: <span class="title function_">async</span> (<span class="attr">configService</span>: <span class="title class_">ConfigService</span>) => ({</span><br><span class="line"> <span class="attr">secret</span>: configService.<span class="property">get</span><<span class="built_in">string</span>>(<span class="string">'JWT_ACCESS_SECRET'</span>),</span><br><span class="line"> <span class="attr">signOptions</span>: {</span><br><span class="line"> <span class="attr">audience</span>: configService.<span class="property">get</span><<span class="built_in">string</span>>(<span class="string">'JWT_TOKEN_AUDIENCE'</span>),</span><br><span class="line"> <span class="attr">issuer</span>: configService.<span class="property">get</span><<span class="built_in">string</span>>(<span class="string">'JWT_TOKEN_ISSUER'</span>),</span><br><span class="line"> <span class="attr">expiresIn</span>: configService.<span class="property">get</span><<span class="built_in">number</span>>(<span class="string">'JWT_ACCESS_TOKEN_TTL'</span>)</span><br><span class="line"> }</span><br><span class="line"> })</span><br><span class="line"> })</span><br><span class="line"> ]</span><br><span class="line">})</span><br></pre></td></tr></tbody></table></figure>
<h4 id="424-更新-jwt-策略"><a class="markdownIt-Anchor" href="#424-更新-jwt-策略"></a> 4.2.4. 更新 JWT 策略</h4>
<p>修改 <code>JwtStrategy</code> 的配置:</p>
<figure class="highlight ts"><figcaption><span>jwt.strategy.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Injectable</span>()</span><br><span class="line"><span class="keyword">export</span> <span class="keyword">class</span> <span class="title class_">JwtStrategy</span> <span class="keyword">extends</span> <span class="title class_ inherited__">PassportStrategy</span>(<span class="title class_">Strategy</span>) {</span><br><span class="line"> <span class="title function_">constructor</span>(<span class="params"><span class="keyword">private</span> <span class="attr">configService</span>: <span class="title class_">ConfigService</span></span>) {</span><br><span class="line"> <span class="variable language_">super</span>({</span><br><span class="line"> <span class="attr">jwtFromRequest</span>: <span class="title class_">ExtractJwt</span>.<span class="title function_">fromAuthHeaderAsBearerToken</span>(),</span><br><span class="line"> <span class="attr">ignoreExpiration</span>: <span class="literal">false</span>,</span><br><span class="line"> <span class="attr">secretOrKey</span>: configService.<span class="property">get</span><<span class="built_in">string</span>>(<span class="string">'JWT_ACCESS_SECRET'</span>),</span><br><span class="line"> <span class="attr">audience</span>: configService.<span class="property">get</span><<span class="built_in">string</span>>(<span class="string">'JWT_TOKEN_AUDIENCE'</span>),</span><br><span class="line"> <span class="attr">issuer</span>: configService.<span class="property">get</span><<span class="built_in">string</span>>(<span class="string">'JWT_TOKEN_ISSUER'</span>)</span><br><span class="line"> });</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
<h2 id="43-令牌生成与签名实现"><a class="markdownIt-Anchor" href="#43-令牌生成与签名实现"></a> 4.3. 令牌生成与签名实现</h2>
<h4 id="431-jwt-配置抽离"><a class="markdownIt-Anchor" href="#431-jwt-配置抽离"></a> 4.3.1. JWT 配置抽离</h4>
<p>为了更好地管理 JWT 相关的配置,我们首先创建了一个专门的配置文件 <code>jwt.config.ts</code>:</p>
<figure class="highlight ts"><figcaption><span>jwt.config.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> { <span class="title class_">ConfigService</span> } <span class="keyword">from</span> <span class="string">'@nestjs/config'</span>;</span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">const</span> <span class="title function_">getJWTVerifyOptions</span> = (<span class="params"><span class="attr">configService</span>: <span class="title class_">ConfigService</span></span>) => ({</span><br><span class="line"> <span class="attr">accessToken</span>: {</span><br><span class="line"> <span class="attr">secret</span>: configService.<span class="title function_">get</span>(<span class="string">'JWT_ACCESS_SECRET'</span>),</span><br><span class="line"> <span class="attr">audience</span>: configService.<span class="title function_">get</span>(<span class="string">'JWT_TOKEN_AUDIENCE'</span>),</span><br><span class="line"> <span class="attr">issuer</span>: configService.<span class="title function_">get</span>(<span class="string">'JWT_TOKEN_ISSUER'</span>),</span><br><span class="line"> <span class="attr">expiresIn</span>: configService.<span class="title function_">get</span>(<span class="string">'JWT_ACCESS_TOKEN_TTL'</span>)</span><br><span class="line"> },</span><br><span class="line"> <span class="attr">refreshToken</span>: {</span><br><span class="line"> <span class="attr">secret</span>: configService.<span class="title function_">get</span>(<span class="string">'JWT_REFRESH_SECRET'</span>),</span><br><span class="line"> <span class="attr">audience</span>: configService.<span class="title function_">get</span>(<span class="string">'JWT_TOKEN_AUDIENCE'</span>),</span><br><span class="line"> <span class="attr">issuer</span>: configService.<span class="title function_">get</span>(<span class="string">'JWT_TOKEN_ISSUER'</span>),</span><br><span class="line"> <span class="attr">expiresIn</span>: configService.<span class="title function_">get</span>(<span class="string">'JWT_REFRESH_TOKEN_TTL'</span>)</span><br><span class="line"> }</span><br><span class="line">});</span><br></pre></td></tr></tbody></table></figure>
<p>这样做的好处是:</p>
<ul>
<li>集中管理 JWT 配置</li>
<li>方便在不同地方复用配置</li>
<li>确保配置的一致性</li>
</ul>
<h4 id="432-数据结构调整"><a class="markdownIt-Anchor" href="#432-数据结构调整"></a> 4.3.2. 数据结构调整</h4>
<ol>
<li>
<p>在 <code>Users</code> 实体中添加 <code>refreshToken</code> 字段,用于存储刷新令牌:</p>
<figure class="highlight ts"><figcaption><span>users.entity.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Column</span>({ <span class="attr">nullable</span>: <span class="literal">true</span> })</span><br><span class="line"><span class="attr">refreshToken</span>?: <span class="built_in">string</span>;</span><br></pre></td></tr></tbody></table></figure>
</li>
<li>
<p>在 <code>LoginDto</code> 中添加「记住我」选项:</p>
<figure class="highlight ts"><figcaption><span>login.dto.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@ApiProperty</span>({ <span class="attr">example</span>: <span class="literal">false</span>, <span class="attr">required</span>: <span class="literal">false</span>, <span class="attr">description</span>: <span class="string">'是否记住我'</span> })</span><br><span class="line"><span class="meta">@IsBoolean</span>()</span><br><span class="line"><span class="meta">@IsOptional</span>()</span><br><span class="line"><span class="attr">rememberMe</span>?: <span class="built_in">boolean</span>;</span><br></pre></td></tr></tbody></table></figure>
</li>
<li>
<p>在 <code>LoginResponseDto</code> 中新增刷新令牌字段:</p>
<figure class="highlight ts"><figcaption><span>login-response.dto.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@ApiProperty</span>({ <span class="attr">description</span>: <span class="string">'刷新令牌'</span> })</span><br><span class="line"><span class="attr">refresh_token</span>: <span class="built_in">string</span>;</span><br></pre></td></tr></tbody></table></figure>
</li>
</ol>
<h4 id="433-认证服务实现"><a class="markdownIt-Anchor" href="#433-认证服务实现"></a> 4.3.3. 认证服务实现</h4>
<p>在 <code>AuthService</code> 中,我们需要进行以下主要改动:</p>
<ol>
<li>
<p>为了支持「记住我」功能,需要在用户登录时生成两种令牌:访问令牌(<code>accessToken</code>)和刷新令牌(<code>refreshToken</code>)。</p>
<ol>
<li>
<p>将原有的令牌生成逻辑修改为:</p>
<figure class="highlight ts"><figcaption><span>auth.service.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 之前的代码</span></span><br><span class="line"><span class="keyword">const</span> accessToken = <span class="variable language_">this</span>.<span class="property">jwtService</span>.<span class="title function_">sign</span>(payload, {</span><br><span class="line"> <span class="attr">audience</span>: <span class="variable language_">this</span>.<span class="property">configService</span>.<span class="property">get</span><<span class="built_in">string</span>>(<span class="string">'JWT_TOKEN_AUDIENCE'</span>),</span><br><span class="line"> <span class="attr">issuer</span>: <span class="variable language_">this</span>.<span class="property">configService</span>.<span class="property">get</span><<span class="built_in">string</span>>(<span class="string">'JWT_TOKEN_ISSUER'</span>),</span><br><span class="line"> <span class="attr">expiresIn</span>: <span class="variable language_">this</span>.<span class="property">configService</span>.<span class="property">get</span><<span class="built_in">number</span>>(<span class="string">'JWT_ACCESS_TOKEN_TTL'</span>)</span><br><span class="line">});</span><br><span class="line"></span><br><span class="line"><span class="comment">// 修改后的代码</span></span><br><span class="line"><span class="keyword">const</span> accessToken = <span class="variable language_">this</span>.<span class="property">jwtService</span>.<span class="title function_">sign</span>(payload);</span><br><span class="line"><span class="keyword">const</span> refreshToken = <span class="variable language_">this</span>.<span class="property">jwtService</span>.<span class="title function_">sign</span>(</span><br><span class="line"> payload, </span><br><span class="line"> <span class="title function_">getJWTVerifyOptions</span>(<span class="variable language_">this</span>.<span class="property">configService</span>).<span class="property">refreshToken</span></span><br><span class="line">);</span><br></pre></td></tr></tbody></table></figure>
</li>
<li>
<p>在登录响应中添加刷新令牌:</p>
<figure class="highlight ts"><figcaption><span>auth.service.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 之前的返回结果</span></span><br><span class="line"><span class="keyword">return</span> {</span><br><span class="line"> <span class="attr">access_token</span>: accessToken,</span><br><span class="line"> <span class="attr">token_type</span>: <span class="string">'Bearer'</span>,</span><br><span class="line"> <span class="attr">expires_in</span>: <span class="variable language_">this</span>.<span class="property">configService</span>.<span class="property">get</span><<span class="built_in">number</span>>(<span class="string">'JWT_ACCESS_TOKEN_TTL'</span>),</span><br><span class="line"> <span class="attr">user</span>: { ... }</span><br><span class="line">};</span><br><span class="line"></span><br><span class="line"><span class="comment">// 修改后的返回结果</span></span><br><span class="line"><span class="keyword">return</span> {</span><br><span class="line"> <span class="attr">access_token</span>: accessToken,</span><br><span class="line"> <span class="attr">refresh_token</span>: refreshToken, <span class="comment">// 新增</span></span><br><span class="line"> <span class="attr">token_type</span>: <span class="string">'Bearer'</span>,</span><br><span class="line"> <span class="attr">expires_in</span>: <span class="variable language_">this</span>.<span class="property">configService</span>.<span class="property">get</span><<span class="built_in">number</span>>(<span class="string">'JWT_ACCESS_TOKEN_TTL'</span>),</span><br><span class="line"> <span class="attr">user</span>: { ... }</span><br><span class="line">};</span><br></pre></td></tr></tbody></table></figure>
</li>
</ol>
</li>
<li>
<p>新增令牌刷新功能,用于在访问令牌过期后获取新的访问令牌:</p>
<figure class="highlight ts"><figcaption><span>auth.service.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">async</span> <span class="title function_">refresh</span>(<span class="params"><span class="attr">refreshToken</span>: <span class="built_in">string</span></span>) {</span><br><span class="line"> <span class="variable language_">this</span>.<span class="property">logger</span>.<span class="title function_">debug</span>(<span class="string">`刷新访问令牌`</span>);</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 验证刷新令牌</span></span><br><span class="line"> <span class="keyword">const</span> refreshPayload = <span class="variable language_">this</span>.<span class="property">jwtService</span>.<span class="title function_">verify</span>(</span><br><span class="line"> refreshToken, </span><br><span class="line"> <span class="title function_">getJWTVerifyOptions</span>(<span class="variable language_">this</span>.<span class="property">configService</span>).<span class="property">refreshToken</span></span><br><span class="line"> );</span><br><span class="line"> </span><br><span class="line"> <span class="comment">// 查找用户</span></span><br><span class="line"> <span class="keyword">const</span> user = <span class="keyword">await</span> <span class="variable language_">this</span>.<span class="property">usersService</span>.<span class="title function_">findUser</span>({ <span class="attr">id</span>: refreshPayload.<span class="property">sub</span> });</span><br><span class="line"> <span class="keyword">if</span> (!user) {</span><br><span class="line"> <span class="variable language_">this</span>.<span class="property">logger</span>.<span class="title function_">warn</span>(<span class="string">`刷新失败,未找到用户:<span class="subst">${user.username}</span>`</span>);</span><br><span class="line"> <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">UnauthorizedException</span>(<span class="string">'用户不存在'</span>);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 生成新的访问令牌</span></span><br><span class="line"> <span class="keyword">const</span> newAccessToken = <span class="variable language_">this</span>.<span class="property">jwtService</span>.<span class="title function_">sign</span>(</span><br><span class="line"> {</span><br><span class="line"> <span class="attr">sub</span>: user.<span class="property">id</span>,</span><br><span class="line"> <span class="attr">username</span>: user.<span class="property">username</span>,</span><br><span class="line"> <span class="attr">email</span>: user.<span class="property">email</span>,</span><br><span class="line"> <span class="attr">role</span>: user.<span class="property">role</span></span><br><span class="line"> },</span><br><span class="line"> <span class="title function_">getJWTVerifyOptions</span>(<span class="variable language_">this</span>.<span class="property">configService</span>).<span class="property">accessToken</span></span><br><span class="line"> );</span><br><span class="line"></span><br><span class="line"> <span class="variable language_">this</span>.<span class="property">logger</span>.<span class="title function_">info</span>(<span class="string">`已为用户刷新访问令牌:<span class="subst">${user.username}</span>`</span>);</span><br><span class="line"></span><br><span class="line"> <span class="keyword">return</span> {</span><br><span class="line"> <span class="attr">access_token</span>: newAccessToken,</span><br><span class="line"> <span class="attr">token_type</span>: <span class="string">'Bearer'</span>,</span><br><span class="line"> <span class="attr">expires_in</span>: <span class="variable language_">this</span>.<span class="property">configService</span>.<span class="property">get</span><<span class="built_in">number</span>>(<span class="string">'JWT_REFRESH_TOKEN_TTL'</span>)</span><br><span class="line"> };</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
</li>
<li>
<p>添加登出功能,用于清除用户的刷新令牌:</p>
<figure class="highlight ts"><figcaption><span>auth.service.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">async</span> <span class="title function_">logout</span>(<span class="attr">userId</span>: <span class="built_in">string</span>): <span class="title class_">Promise</span><<span class="built_in">void</span>> {</span><br><span class="line"> <span class="keyword">await</span> <span class="variable language_">this</span>.<span class="property">usersService</span>.<span class="title function_">update</span>(userId, { <span class="attr">refreshToken</span>: <span class="literal">null</span> });</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
</li>
</ol>
<h4 id="434-控制器层改造"><a class="markdownIt-Anchor" href="#434-控制器层改造"></a> 4.3.4. 控制器层改造</h4>
<p>在 <code>AuthController</code> 中,我们需要添加新的接口来支持「记住我」功能:</p>
<figure class="highlight ts"><figcaption><span>auth.controller.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@ApiOperation</span>({ <span class="attr">summary</span>: <span class="string">'刷新访问令牌'</span> })</span><br><span class="line"><span class="meta">@UseGuards</span>(<span class="title class_">JwtRefreshGuard</span>)</span><br><span class="line"><span class="meta">@HttpCode</span>(<span class="title class_">HttpStatus</span>.<span class="property">OK</span>)</span><br><span class="line"><span class="meta">@Post</span>(<span class="string">'refresh'</span>)</span><br><span class="line"><span class="keyword">async</span> <span class="title function_">refresh</span>(<span class="params"><span class="meta">@Body</span>() <span class="attr">body</span>: { refreshToken: <span class="built_in">string</span> }</span>) {</span><br><span class="line"> <span class="keyword">try</span> {</span><br><span class="line"> <span class="keyword">return</span> <span class="keyword">await</span> <span class="variable language_">this</span>.<span class="property">authService</span>.<span class="title function_">refresh</span>(body.<span class="property">refreshToken</span>);</span><br><span class="line"> } <span class="keyword">catch</span> (error) {</span><br><span class="line"> <span class="variable language_">this</span>.<span class="property">logger</span>.<span class="title function_">error</span>(<span class="string">`刷新 Token 失败:<span class="subst">${error.message}</span>`</span>);</span><br><span class="line"> <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">UnauthorizedException</span>(<span class="string">'刷新 Token 失败'</span>);</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
<p>同时添加获取当前用户信息和登出的接口:</p>
<figure class="highlight ts"><figcaption><span>auth.controller.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@ApiOperation</span>({ <span class="attr">summary</span>: <span class="string">'获取当前用户信息'</span> })</span><br><span class="line"><span class="meta">@HttpCode</span>(<span class="title class_">HttpStatus</span>.<span class="property">OK</span>)</span><br><span class="line"><span class="meta">@Get</span>(<span class="string">'me'</span>)</span><br><span class="line"><span class="keyword">async</span> <span class="title function_">getCurrentUser</span>(<span class="params"><span class="meta">@CurrentUser</span>() <span class="attr">user</span>: <span class="title class_">JWTPayload</span></span>) {</span><br><span class="line"> <span class="keyword">return</span> <span class="variable language_">this</span>.<span class="property">authService</span>.<span class="title function_">getCurrentUser</span>(user.<span class="property">sub</span>);</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="meta">@ApiOperation</span>({ <span class="attr">summary</span>: <span class="string">'登出用户'</span> })</span><br><span class="line"><span class="meta">@HttpCode</span>(<span class="title class_">HttpStatus</span>.<span class="property">OK</span>)</span><br><span class="line"><span class="meta">@UseGuards</span>(<span class="title class_">JwtAuthGuard</span>)</span><br><span class="line"><span class="meta">@Post</span>(<span class="string">'logout'</span>)</span><br><span class="line"><span class="keyword">async</span> <span class="title function_">logout</span>(<span class="params"><span class="meta">@Req</span>() <span class="attr">req</span>: <span class="title class_">Request</span></span>) {</span><br><span class="line"> <span class="keyword">const</span> user = req.<span class="property">user</span> <span class="keyword">as</span> <span class="title class_">JWTPayload</span>;</span><br><span class="line"> <span class="keyword">await</span> <span class="variable language_">this</span>.<span class="property">authService</span>.<span class="title function_">logout</span>(user.<span class="property">sub</span>);</span><br><span class="line"> <span class="keyword">return</span> { <span class="attr">message</span>: <span class="string">'已登出'</span> };</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
<h4 id="435-守卫机制完善"><a class="markdownIt-Anchor" href="#435-守卫机制完善"></a> 4.3.5. 守卫机制完善</h4>
<ol>
<li>
<p>访问令牌守卫</p>
<p>原来的 <code>JwtAuthGuard</code> 实现存在几个问题:</p>
<ul>
<li>没有直接验证 <code>token</code> 的有效性</li>
<li>没有正确处理 <code>audience</code> 和 <code>issuer</code> 的验证</li>
</ul>
<p>看一眼原本的代码:</p>
<figure class="highlight ts"><figcaption><span>jwt-auth.guard.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Injectable</span>()</span><br><span class="line"><span class="keyword">export</span> <span class="keyword">class</span> <span class="title class_">JwtAuthGuard</span> <span class="keyword">extends</span> <span class="title class_ inherited__">AuthGuard</span>(<span class="string">'jwt'</span>) {</span><br><span class="line"> <span class="title function_">handleRequest</span>(<span class="params"><span class="attr">error</span>: <span class="built_in">any</span>, <span class="attr">user</span>: <span class="built_in">any</span>, <span class="attr">info</span>: <span class="built_in">any</span></span>) {</span><br><span class="line"> <span class="keyword">if</span> (error || !user) {</span><br><span class="line"> <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">UnauthorizedException</span>(<span class="string">'验证失败'</span>);</span><br><span class="line"> }</span><br><span class="line"> <span class="keyword">return</span> user;</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
<p>现在,我们重新实现了这个守卫,提供了更严格的验证和更好的错误处理:</p>
<figure class="highlight ts"><figcaption><span>jwt-auth.guard.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> { <span class="title class_">CanActivate</span>, <span class="title class_">ExecutionContext</span>, <span class="title class_">Injectable</span>, <span class="title class_">UnauthorizedException</span> } <span class="keyword">from</span> <span class="string">'@nestjs/common'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">JwtService</span> } <span class="keyword">from</span> <span class="string">'@nestjs/jwt'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">ConfigService</span> } <span class="keyword">from</span> <span class="string">'@nestjs/config'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">TokenUtils</span> } <span class="keyword">from</span> <span class="string">'../utils/token.utils'</span>;</span><br><span class="line"><span class="keyword">import</span> winstonLogger <span class="keyword">from</span> <span class="string">'../../loggers/winston.logger'</span>;</span><br><span class="line"></span><br><span class="line"><span class="meta">@Injectable</span>()</span><br><span class="line"><span class="keyword">export</span> <span class="keyword">class</span> <span class="title class_">JwtAuthGuard</span> <span class="keyword">implements</span> <span class="title class_">CanActivate</span> {</span><br><span class="line"> <span class="keyword">private</span> logger = winstonLogger;</span><br><span class="line"></span><br><span class="line"> <span class="title function_">constructor</span>(<span class="params"></span></span><br><span class="line"><span class="params"> <span class="keyword">private</span> <span class="attr">jwtService</span>: <span class="title class_">JwtService</span>,</span></span><br><span class="line"><span class="params"> <span class="keyword">private</span> <span class="attr">configService</span>: <span class="title class_">ConfigService</span></span></span><br><span class="line"><span class="params"> </span>) {}</span><br><span class="line"></span><br><span class="line"> <span class="keyword">async</span> <span class="title function_">canActivate</span>(<span class="attr">context</span>: <span class="title class_">ExecutionContext</span>): <span class="title class_">Promise</span><<span class="built_in">boolean</span>> {</span><br><span class="line"> <span class="keyword">const</span> token = <span class="title class_">TokenUtils</span>.<span class="title function_">extractTokenFromContext</span>(context);</span><br><span class="line"></span><br><span class="line"> <span class="keyword">if</span> (!token) {</span><br><span class="line"> <span class="variable language_">this</span>.<span class="property">logger</span>.<span class="title function_">warn</span>(<span class="string">`未提供访问令牌:<span class="subst">${context}</span>`</span>);</span><br><span class="line"> <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">UnauthorizedException</span>(<span class="string">'未提供访问令牌'</span>);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">try</span> {</span><br><span class="line"> <span class="keyword">const</span> request = context.<span class="title function_">switchToHttp</span>().<span class="title function_">getRequest</span>();</span><br><span class="line"> request.<span class="property">user</span> = <span class="keyword">await</span> <span class="variable language_">this</span>.<span class="property">jwtService</span>.<span class="title function_">verifyAsync</span>(token, {</span><br><span class="line"> <span class="attr">audience</span>: <span class="variable language_">this</span>.<span class="property">configService</span>.<span class="property">get</span><<span class="built_in">string</span>>(<span class="string">'JWT_TOKEN_AUDIENCE'</span>),</span><br><span class="line"> <span class="attr">issuer</span>: <span class="variable language_">this</span>.<span class="property">configService</span>.<span class="property">get</span><<span class="built_in">string</span>>(<span class="string">'JWT_TOKEN_ISSUER'</span>)</span><br><span class="line"> });</span><br><span class="line"> <span class="keyword">return</span> <span class="literal">true</span>;</span><br><span class="line"> } <span class="keyword">catch</span> (error) {</span><br><span class="line"> <span class="variable language_">this</span>.<span class="property">logger</span>.<span class="title function_">error</span>(<span class="string">`验证访问令牌失败:<span class="subst">${error.message}</span>`</span>, error.<span class="property">stack</span>);</span><br><span class="line"> <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">UnauthorizedException</span>(<span class="string">'无效的访问令牌'</span>);</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
</li>
<li>
<p>刷新令牌守卫</p>
<p>新增 <code>JwtRefreshGuard</code> 专门用于处理刷新令牌的验证:</p>
<figure class="highlight ts"><figcaption><span>jwt-refresh.guard.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> { <span class="title class_">ExecutionContext</span>, <span class="title class_">Injectable</span>, <span class="title class_">UnauthorizedException</span> } <span class="keyword">from</span> <span class="string">'@nestjs/common'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">ConfigService</span> } <span class="keyword">from</span> <span class="string">'@nestjs/config'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">JwtService</span> } <span class="keyword">from</span> <span class="string">'@nestjs/jwt'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">TokenUtils</span> } <span class="keyword">from</span> <span class="string">'../utils/token.utils'</span>;</span><br><span class="line"><span class="keyword">import</span> { getJWTVerifyOptions } <span class="keyword">from</span> <span class="string">'../../config/jwt.config'</span>;</span><br><span class="line"><span class="keyword">import</span> winstonLogger <span class="keyword">from</span> <span class="string">'../../loggers/winston.logger'</span>;</span><br><span class="line"></span><br><span class="line"><span class="meta">@Injectable</span>()</span><br><span class="line"><span class="keyword">export</span> <span class="keyword">class</span> <span class="title class_">JwtRefreshGuard</span> {</span><br><span class="line"> <span class="keyword">private</span> logger = winstonLogger;</span><br><span class="line"></span><br><span class="line"> <span class="title function_">constructor</span>(<span class="params"></span></span><br><span class="line"><span class="params"> <span class="keyword">private</span> <span class="attr">jwtService</span>: <span class="title class_">JwtService</span>,</span></span><br><span class="line"><span class="params"> <span class="keyword">private</span> <span class="attr">configService</span>: <span class="title class_">ConfigService</span></span></span><br><span class="line"><span class="params"> </span>) {}</span><br><span class="line"></span><br><span class="line"> <span class="keyword">async</span> <span class="title function_">canActivate</span>(<span class="params"><span class="attr">context</span>: <span class="title class_">ExecutionContext</span></span>) {</span><br><span class="line"> <span class="keyword">const</span> token = <span class="title class_">TokenUtils</span>.<span class="title function_">extractTokenFromContext</span>(context);</span><br><span class="line"> <span class="keyword">const</span> options = <span class="title function_">getJWTVerifyOptions</span>(<span class="variable language_">this</span>.<span class="property">configService</span>).<span class="property">refreshToken</span>;</span><br><span class="line"></span><br><span class="line"> <span class="keyword">try</span> {</span><br><span class="line"> <span class="variable language_">this</span>.<span class="property">jwtService</span>.<span class="title function_">verify</span>(token, options);</span><br><span class="line"> <span class="keyword">return</span> <span class="literal">true</span>;</span><br><span class="line"> } <span class="keyword">catch</span> (error) {</span><br><span class="line"> <span class="variable language_">this</span>.<span class="property">logger</span>.<span class="title function_">error</span>(<span class="string">`验证刷新令牌失败:<span class="subst">${error.message}</span>`</span>, error.<span class="property">stack</span>);</span><br><span class="line"> <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">UnauthorizedException</span>(<span class="string">'无效的刷新令牌'</span>);</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
</li>
</ol>
<h4 id="436-工具类支持"><a class="markdownIt-Anchor" href="#436-工具类支持"></a> 4.3.6. 工具类支持</h4>
<p>在之前的实现中,令牌提取的逻辑分散在各处,容易导致处理不一致。为了统一处理令牌的提取逻辑,新增 <code>TokenUtils</code> 工具类:</p>
<figure class="highlight ts"><figcaption><span>token.utils.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> { <span class="title class_">ExecutionContext</span> } <span class="keyword">from</span> <span class="string">'@nestjs/common'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">Request</span> } <span class="keyword">from</span> <span class="string">'express'</span>;</span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">class</span> <span class="title class_">TokenUtils</span> {</span><br><span class="line"> <span class="keyword">static</span> <span class="title function_">extractToken</span>(<span class="attr">request</span>: <span class="title class_">Request</span>): <span class="built_in">string</span> | <span class="literal">undefined</span> {</span><br><span class="line"> <span class="keyword">const</span> [<span class="keyword">type</span>, token] = request.<span class="property">headers</span>.<span class="property">authorization</span>?.<span class="title function_">split</span>(<span class="string">' '</span>) ?? [];</span><br><span class="line"> <span class="keyword">return</span> <span class="keyword">type</span> === <span class="string">'Bearer'</span> ? token : <span class="literal">undefined</span>;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">static</span> <span class="title function_">extractTokenFromContext</span>(<span class="attr">context</span>: <span class="title class_">ExecutionContext</span>): <span class="built_in">string</span> | <span class="literal">undefined</span> {</span><br><span class="line"> <span class="keyword">const</span> request = context.<span class="title function_">switchToHttp</span>().<span class="property">getRequest</span><<span class="title class_">Request</span>>();</span><br><span class="line"> <span class="keyword">return</span> <span class="variable language_">this</span>.<span class="title function_">extractToken</span>(request);</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
<h2 id="44-客户端功能实现"><a class="markdownIt-Anchor" href="#44-客户端功能实现"></a> 4.4. 客户端功能实现</h2>
<h4 id="441-api-请求封装"><a class="markdownIt-Anchor" href="#441-api-请求封装"></a> 4.4.1. API 请求封装</h4>
<p>在用户登录后,我们需要确保所有 API 请求都能正确携带身份验证信息。然而,<code>accessToken</code> 可能会过期,如果没有自动刷新机制,用户将频繁被登出,影响体验。我们希望实现一个自动处理令牌的方案,使得:</p>
<ul>
<li>每个请求都自动携带 <code>accessToken</code> 进行身份验证。</li>
<li>当 <code>accessToken</code> 过期时,自动使用 <code>refreshToken</code> 获取新的 <code>accessToken</code>,并重试原请求。</li>
<li>如果 <code>refreshToken</code> 也失效,则清除令牌并让用户重新登录。</li>
</ul>
<p>我们使用 Axios 拦截器 来实现这一机制,主要分为两部分:</p>
<ol>
<li>
<p>请求拦截器:</p>
<ul>
<li>在每个请求发送前,自动读取 <code>accessToken</code>,并将其添加到请求头。</li>
<li>如果 <code>accessToken</code> 不存在,则直接发送请求。</li>
</ul>
</li>
<li>
<p>响应拦截器:</p>
<ul>
<li>监听 API 响应,如果返回 <code>401 Unauthorized</code>,说明 <code>accessToken</code> 可能已过期。</li>
<li>如果 <code>refreshToken</code> 仍然有效,则使用它请求新的 <code>accessToken</code>,然后重试原始请求。</li>
<li>如果 <code>refreshToken</code> 失效,则清除所有令牌,并要求用户重新登录。</li>
</ul>
</li>
</ol>
<p>这是客户端请求与响应拦截逻辑的流程:</p>
<svg aria-roledescription="flowchart-v2" role="graphics-document document" viewBox="-7.5 -8 497.21875 266.0390625" style="max-width: 497.21875px;" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="100%" id="mermaid-1771490040219"><style>#mermaid-1771490040219{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-1771490040219 .error-icon{fill:#552222;}#mermaid-1771490040219 .error-text{fill:#552222;stroke:#552222;}#mermaid-1771490040219 .edge-thickness-normal{stroke-width:2px;}#mermaid-1771490040219 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-1771490040219 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-1771490040219 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-1771490040219 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-1771490040219 .marker{fill:#333333;stroke:#333333;}#mermaid-1771490040219 .marker.cross{stroke:#333333;}#mermaid-1771490040219 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-1771490040219 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-1771490040219 .cluster-label text{fill:#333;}#mermaid-1771490040219 .cluster-label span,#mermaid-1771490040219 p{color:#333;}#mermaid-1771490040219 .label text,#mermaid-1771490040219 span,#mermaid-1771490040219 p{fill:#333;color:#333;}#mermaid-1771490040219 .node rect,#mermaid-1771490040219 .node circle,#mermaid-1771490040219 .node ellipse,#mermaid-1771490040219 .node polygon,#mermaid-1771490040219 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-1771490040219 .flowchart-label text{text-anchor:middle;}#mermaid-1771490040219 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-1771490040219 .node .label{text-align:center;}#mermaid-1771490040219 .node.clickable{cursor:pointer;}#mermaid-1771490040219 .arrowheadPath{fill:#333333;}#mermaid-1771490040219 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-1771490040219 .flowchart-link{stroke:#333333;fill:none;}#mermaid-1771490040219 .edgeLabel{background-color:#e8e8e8;text-align:center;}#mermaid-1771490040219 .edgeLabel rect{opacity:0.5;background-color:#e8e8e8;fill:#e8e8e8;}#mermaid-1771490040219 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-1771490040219 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-1771490040219 .cluster text{fill:#333;}#mermaid-1771490040219 .cluster span,#mermaid-1771490040219 p{color:#333;}#mermaid-1771490040219 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-1771490040219 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-1771490040219 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}</style><g><marker orient="auto" markerHeight="12" markerWidth="12" markerUnits="userSpaceOnUse" refY="5" refX="6" viewBox="0 0 10 10" class="marker flowchart" id="mermaid-1771490040219_flowchart-pointEnd"><path style="stroke-width: 1; stroke-dasharray: 1, 0;" class="arrowMarkerPath" d="M 0 0 L 10 5 L 0 10 z"></path></marker><marker orient="auto" markerHeight="12" markerWidth="12" markerUnits="userSpaceOnUse" refY="5" refX="4.5" viewBox="0 0 10 10" class="marker flowchart" id="mermaid-1771490040219_flowchart-pointStart"><path style="stroke-width: 1; stroke-dasharray: 1, 0;" class="arrowMarkerPath" d="M 0 5 L 10 10 L 10 0 z"></path></marker><marker orient="auto" markerHeight="11" markerWidth="11" markerUnits="userSpaceOnUse" refY="5" refX="11" viewBox="0 0 10 10" class="marker flowchart" id="mermaid-1771490040219_flowchart-circleEnd"><circle style="stroke-width: 1; stroke-dasharray: 1, 0;" class="arrowMarkerPath" r="5" cy="5" cx="5"></circle></marker><marker orient="auto" markerHeight="11" markerWidth="11" markerUnits="userSpaceOnUse" refY="5" refX="-1" viewBox="0 0 10 10" class="marker flowchart" id="mermaid-1771490040219_flowchart-circleStart"><circle style="stroke-width: 1; stroke-dasharray: 1, 0;" class="arrowMarkerPath" r="5" cy="5" cx="5"></circle></marker><marker orient="auto" markerHeight="11" markerWidth="11" markerUnits="userSpaceOnUse" refY="5.2" refX="12" viewBox="0 0 11 11" class="marker cross flowchart" id="mermaid-1771490040219_flowchart-crossEnd"><path style="stroke-width: 2; stroke-dasharray: 1, 0;" class="arrowMarkerPath" d="M 1,1 l 9,9 M 10,1 l -9,9"></path></marker><marker orient="auto" markerHeight="11" markerWidth="11" markerUnits="userSpaceOnUse" refY="5.2" refX="-1" viewBox="0 0 11 11" class="marker cross flowchart" id="mermaid-1771490040219_flowchart-crossStart"><path style="stroke-width: 2; stroke-dasharray: 1, 0;" class="arrowMarkerPath" d="M 1,1 l 9,9 M 10,1 l -9,9"></path></marker><g class="root"><g class="clusters"></g><g class="edgePaths"></g><g class="edgeLabels"></g><g class="nodes"><g transform="translate(-7.5, -8)" class="root"><g class="clusters"><g id="请求拦截" class="cluster default flowchart-label"><rect height="250.0390625" width="481.21875" y="8" x="8" ry="0" rx="0" style=""></rect><g transform="translate(236.921875, 8)" class="cluster-label"><foreignObject height="18" width="23.375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">请求拦截</span></div></foreignObject></g></g></g><g class="edgePaths"><path marker-end="url(#mermaid-1771490040219_flowchart-pointEnd)" style="fill:none;" class="edge-thickness-normal edge-pattern-solid flowchart-link LS-A LE-B" id="L-A-B-0" d="M71.375,133.02L75.542,133.02C79.708,133.02,88.042,133.02,95.575,133.086C103.109,133.152,109.842,133.284,113.209,133.35L116.576,133.416"></path><path marker-end="url(#mermaid-1771490040219_flowchart-pointEnd)" style="fill:none;" class="edge-thickness-normal edge-pattern-solid flowchart-link LS-B LE-C" id="L-B-C-0" d="M260.665,111.231L268.95,107.946C277.235,104.661,293.805,98.09,305.86,94.805C317.916,91.52,325.456,91.52,329.227,91.52L332.997,91.52"></path><path marker-end="url(#mermaid-1771490040219_flowchart-pointEnd)" style="fill:none;" class="edge-thickness-normal edge-pattern-solid flowchart-link LS-B LE-D" id="L-B-D-0" d="M260.665,155.808L268.95,158.926C277.235,162.045,293.805,168.282,313.156,171.401C332.507,174.52,354.639,174.52,365.704,174.52L376.77,174.52"></path></g><g class="edgeLabels"><g class="edgeLabel"><g transform="translate(0, 0)" class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"></span></div></foreignObject></g></g><g transform="translate(310.375, 91.51953125)" class="edgeLabel"><g transform="translate(-2.921875, -9)" class="label"><foreignObject height="18" width="5.84375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">是</span></div></foreignObject></g></g><g transform="translate(310.375, 174.51953125)" class="edgeLabel"><g transform="translate(-2.921875, -9)" class="label"><foreignObject height="18" width="5.84375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">否</span></div></foreignObject></g></g></g><g class="nodes"><g transform="translate(201.9140625, 133.01953125)" data-id="B" data-node="true" id="flowchart-B-1" class="node default default flowchart-label"><polygon style="" transform="translate(-80.5390625,80.5390625)" class="label-container" points="80.5390625,0 161.078125,-80.5390625 80.5390625,-161.078125 0,-80.5390625"></polygon><g transform="translate(-56.5390625, -9)" style="" class="label"><rect></rect><foreignObject height="18" width="113.078125"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">存在 accessToken?</span></div></foreignObject></g></g><g transform="translate(52.1875, 133.01953125)" data-id="A" data-node="true" id="flowchart-A-0" class="node default default flowchart-label"><rect height="33" width="38.375" y="-16.5" x="-19.1875" ry="0" rx="0" style="" class="basic label-container"></rect><g transform="translate(-11.6875, -9)" style="" class="label"><rect></rect><foreignObject height="18" width="23.375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">发起请求</span></div></foreignObject></g></g><g transform="translate(401.2578125, 91.51953125)" data-id="C" data-node="true" id="flowchart-C-3" class="node default default flowchart-label"><rect height="33" width="125.921875" y="-16.5" x="-62.9609375" ry="0" rx="0" style="" class="basic label-container"></rect><g transform="translate(-55.4609375, -9)" style="" class="label"><rect></rect><foreignObject height="18" width="110.921875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">添加 Authorization 头</span></div></foreignObject></g></g><g transform="translate(401.2578125, 174.51953125)" data-id="D" data-node="true" id="flowchart-D-5" class="node default default flowchart-label"><rect height="33" width="38.375" y="-16.5" x="-19.1875" ry="0" rx="0" style="" class="basic label-container"></rect><g transform="translate(-11.6875, -9)" style="" class="label"><rect></rect><foreignObject height="18" width="23.375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">直接发送</span></div></foreignObject></g></g></g></g></g></g></g></svg>
<svg aria-roledescription="flowchart-v2" role="graphics-document document" viewBox="-7.5 -8 910.515625 369.61328125" style="max-width: 910.515625px;" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="100%" id="mermaid-1771490042646"><style>#mermaid-1771490042646{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-1771490042646 .error-icon{fill:#552222;}#mermaid-1771490042646 .error-text{fill:#552222;stroke:#552222;}#mermaid-1771490042646 .edge-thickness-normal{stroke-width:2px;}#mermaid-1771490042646 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-1771490042646 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-1771490042646 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-1771490042646 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-1771490042646 .marker{fill:#333333;stroke:#333333;}#mermaid-1771490042646 .marker.cross{stroke:#333333;}#mermaid-1771490042646 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-1771490042646 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-1771490042646 .cluster-label text{fill:#333;}#mermaid-1771490042646 .cluster-label span,#mermaid-1771490042646 p{color:#333;}#mermaid-1771490042646 .label text,#mermaid-1771490042646 span,#mermaid-1771490042646 p{fill:#333;color:#333;}#mermaid-1771490042646 .node rect,#mermaid-1771490042646 .node circle,#mermaid-1771490042646 .node ellipse,#mermaid-1771490042646 .node polygon,#mermaid-1771490042646 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-1771490042646 .flowchart-label text{text-anchor:middle;}#mermaid-1771490042646 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-1771490042646 .node .label{text-align:center;}#mermaid-1771490042646 .node.clickable{cursor:pointer;}#mermaid-1771490042646 .arrowheadPath{fill:#333333;}#mermaid-1771490042646 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-1771490042646 .flowchart-link{stroke:#333333;fill:none;}#mermaid-1771490042646 .edgeLabel{background-color:#e8e8e8;text-align:center;}#mermaid-1771490042646 .edgeLabel rect{opacity:0.5;background-color:#e8e8e8;fill:#e8e8e8;}#mermaid-1771490042646 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-1771490042646 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-1771490042646 .cluster text{fill:#333;}#mermaid-1771490042646 .cluster span,#mermaid-1771490042646 p{color:#333;}#mermaid-1771490042646 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-1771490042646 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-1771490042646 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}</style><g><marker orient="auto" markerHeight="12" markerWidth="12" markerUnits="userSpaceOnUse" refY="5" refX="6" viewBox="0 0 10 10" class="marker flowchart" id="mermaid-1771490042646_flowchart-pointEnd"><path style="stroke-width: 1; stroke-dasharray: 1, 0;" class="arrowMarkerPath" d="M 0 0 L 10 5 L 0 10 z"></path></marker><marker orient="auto" markerHeight="12" markerWidth="12" markerUnits="userSpaceOnUse" refY="5" refX="4.5" viewBox="0 0 10 10" class="marker flowchart" id="mermaid-1771490042646_flowchart-pointStart"><path style="stroke-width: 1; stroke-dasharray: 1, 0;" class="arrowMarkerPath" d="M 0 5 L 10 10 L 10 0 z"></path></marker><marker orient="auto" markerHeight="11" markerWidth="11" markerUnits="userSpaceOnUse" refY="5" refX="11" viewBox="0 0 10 10" class="marker flowchart" id="mermaid-1771490042646_flowchart-circleEnd"><circle style="stroke-width: 1; stroke-dasharray: 1, 0;" class="arrowMarkerPath" r="5" cy="5" cx="5"></circle></marker><marker orient="auto" markerHeight="11" markerWidth="11" markerUnits="userSpaceOnUse" refY="5" refX="-1" viewBox="0 0 10 10" class="marker flowchart" id="mermaid-1771490042646_flowchart-circleStart"><circle style="stroke-width: 1; stroke-dasharray: 1, 0;" class="arrowMarkerPath" r="5" cy="5" cx="5"></circle></marker><marker orient="auto" markerHeight="11" markerWidth="11" markerUnits="userSpaceOnUse" refY="5.2" refX="12" viewBox="0 0 11 11" class="marker cross flowchart" id="mermaid-1771490042646_flowchart-crossEnd"><path style="stroke-width: 2; stroke-dasharray: 1, 0;" class="arrowMarkerPath" d="M 1,1 l 9,9 M 10,1 l -9,9"></path></marker><marker orient="auto" markerHeight="11" markerWidth="11" markerUnits="userSpaceOnUse" refY="5.2" refX="-1" viewBox="0 0 11 11" class="marker cross flowchart" id="mermaid-1771490042646_flowchart-crossStart"><path style="stroke-width: 2; stroke-dasharray: 1, 0;" class="arrowMarkerPath" d="M 1,1 l 9,9 M 10,1 l -9,9"></path></marker><g class="root"><g class="clusters"></g><g class="edgePaths"></g><g class="edgeLabels"></g><g class="nodes"><g transform="translate(-7.5, -8)" class="root"><g class="clusters"><g id="响应拦截" class="cluster default flowchart-label"><rect height="353.61328125" width="894.515625" y="8" x="8" ry="0" rx="0" style=""></rect><g transform="translate(443.5703125, 8)" class="cluster-label"><foreignObject height="18" width="23.375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">响应拦截</span></div></foreignObject></g></g></g><g class="edgePaths"><path marker-end="url(#mermaid-1771490042646_flowchart-pointEnd)" style="fill:none;" class="edge-thickness-normal edge-pattern-solid flowchart-link LS-E LE-F" id="L-E-F-0" d="M71.375,218.688L75.542,218.688C79.708,218.688,88.042,218.688,95.575,218.754C103.109,218.82,109.842,218.952,113.209,219.018L116.576,219.084"></path><path marker-end="url(#mermaid-1771490042646_flowchart-pointEnd)" style="fill:none;" class="edge-thickness-normal edge-pattern-solid flowchart-link LS-F LE-G" id="L-F-G-0" d="M206.701,193.545L215.545,185.726C224.389,177.906,242.077,162.268,254.775,154.516C267.473,146.764,275.181,146.9,279.035,146.968L282.888,147.036"></path><path marker-end="url(#mermaid-1771490042646_flowchart-pointEnd)" style="fill:none;" class="edge-thickness-normal edge-pattern-solid flowchart-link LS-G LE-H" id="L-G-H-0" d="M423.045,126.752L431.012,123.803C438.978,120.854,454.911,114.956,466.648,112.008C478.384,109.059,485.925,109.059,489.695,109.059L493.466,109.059"></path><path marker-end="url(#mermaid-1771490042646_flowchart-pointEnd)" style="fill:none;" class="edge-thickness-normal edge-pattern-solid flowchart-link LS-H LE-I" id="L-H-I-0" d="M548.828,109.059L553.482,109.059C558.135,109.059,567.443,109.059,575.95,109.126C584.458,109.194,592.165,109.33,596.019,109.398L599.873,109.465"></path><path marker-end="url(#mermaid-1771490042646_flowchart-pointEnd)" style="fill:none;" class="edge-thickness-normal edge-pattern-solid flowchart-link LS-I LE-J" id="L-I-J-0" d="M676.693,100.799L682.723,99.009C688.754,97.219,700.814,93.639,710.615,91.849C720.416,90.059,727.956,90.059,731.727,90.059L735.497,90.059"></path><path marker-end="url(#mermaid-1771490042646_flowchart-pointEnd)" style="fill:none;" class="edge-thickness-normal edge-pattern-solid flowchart-link LS-I LE-K" id="L-I-K-0" d="M665.273,129.738L673.207,137.76C681.141,145.781,697.008,161.824,716.48,174.348C735.953,186.873,759.031,195.878,770.57,200.381L782.109,204.883"></path><path marker-end="url(#mermaid-1771490042646_flowchart-pointEnd)" style="fill:none;" class="edge-thickness-normal edge-pattern-solid flowchart-link LS-G LE-K" id="L-G-K-0" d="M409.182,181.369L419.459,189.464C429.736,197.559,450.29,213.748,469.392,221.843C488.495,229.938,506.146,229.938,523.797,229.938C541.448,229.938,559.099,229.938,579.268,229.938C599.438,229.938,622.125,229.938,644.813,229.938C667.5,229.938,690.188,229.938,713.02,228.207C735.852,226.477,758.829,223.017,770.317,221.287L781.806,219.556"></path><path marker-end="url(#mermaid-1771490042646_flowchart-pointEnd)" style="fill:none;" class="edge-thickness-normal edge-pattern-solid flowchart-link LS-F LE-L" id="L-F-L-0" d="M206.701,244.83L215.545,252.483C224.389,260.135,242.077,275.441,264.43,283.093C286.783,290.746,313.8,290.746,327.309,290.746L340.817,290.746"></path></g><g class="edgeLabels"><g class="edgeLabel"><g transform="translate(0, 0)" class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"></span></div></foreignObject></g></g><g transform="translate(259.765625, 146.62890625)" class="edgeLabel"><g transform="translate(-2.921875, -9)" class="label"><foreignObject height="18" width="5.84375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">是</span></div></foreignObject></g></g><g transform="translate(470.84375, 109.05859375)" class="edgeLabel"><g transform="translate(-2.921875, -9)" class="label"><foreignObject height="18" width="5.84375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">是</span></div></foreignObject></g></g><g class="edgeLabel"><g transform="translate(0, 0)" class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"></span></div></foreignObject></g></g><g transform="translate(712.875, 90.05859375)" class="edgeLabel"><g transform="translate(-2.921875, -9)" class="label"><foreignObject height="18" width="5.84375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">是</span></div></foreignObject></g></g><g transform="translate(712.875, 177.8671875)" class="edgeLabel"><g transform="translate(-2.921875, -9)" class="label"><foreignObject height="18" width="5.84375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">否</span></div></foreignObject></g></g><g transform="translate(576.75, 229.9375)" class="edgeLabel"><g transform="translate(-2.921875, -9)" class="label"><foreignObject height="18" width="5.84375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">否</span></div></foreignObject></g></g><g transform="translate(259.765625, 290.74609375)" class="edgeLabel"><g transform="translate(-2.921875, -9)" class="label"><foreignObject height="18" width="5.84375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">否</span></div></foreignObject></g></g></g><g class="nodes"><g transform="translate(176.609375, 218.6875)" data-id="F" data-node="true" id="flowchart-F-1" class="node default default flowchart-label"><polygon style="" transform="translate(-55.234375,55.234375)" class="label-container" points="55.234375,0 110.46875,-55.234375 55.234375,-110.46875 0,-55.234375"></polygon><g transform="translate(-31.234375, -9)" style="" class="label"><rect></rect><foreignObject height="18" width="62.46875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">状态码 = 401?</span></div></foreignObject></g></g><g transform="translate(52.1875, 218.6875)" data-id="E" data-node="true" id="flowchart-E-0" class="node default default flowchart-label"><rect height="33" width="38.375" y="-16.5" x="-19.1875" ry="0" rx="0" style="" class="basic label-container"></rect><g transform="translate(-11.6875, -9)" style="" class="label"><rect></rect><foreignObject height="18" width="23.375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">接收响应</span></div></foreignObject></g></g><g transform="translate(365.3046875, 146.62890625)" data-id="G" data-node="true" id="flowchart-G-3" class="node default default flowchart-label"><polygon style="" transform="translate(-77.6171875,77.6171875)" class="label-container" points="77.6171875,0 155.234375,-77.6171875 77.6171875,-155.234375 0,-77.6171875"></polygon><g transform="translate(-53.6171875, -9)" style="" class="label"><rect></rect><foreignObject height="18" width="107.234375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">有 refreshToken?</span></div></foreignObject></g></g><g transform="translate(523.796875, 109.05859375)" data-id="H" data-node="true" id="flowchart-H-5" class="node default default flowchart-label"><rect height="33" width="50.0625" y="-16.5" x="-25.03125" ry="0" rx="0" style="" class="basic label-container"></rect><g transform="translate(-17.53125, -9)" style="" class="label"><rect></rect><foreignObject height="18" width="35.0625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">发起刷新请求</span></div></foreignObject></g></g><g transform="translate(644.8125, 109.05859375)" data-id="I" data-node="true" id="flowchart-I-7" class="node default default flowchart-label"><polygon style="" transform="translate(-40.140625,40.140625)" class="label-container" points="40.140625,0 80.28125,-40.140625 40.140625,-80.28125 0,-40.140625"></polygon><g transform="translate(-16.140625, -9)" style="" class="label"><rect></rect><foreignObject height="18" width="32.28125"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">刷新成功?</span></div></foreignObject></g></g><g transform="translate(809.15625, 90.05859375)" data-id="J" data-node="true" id="flowchart-J-9" class="node default default flowchart-label"><rect height="33" width="136.71875" y="-16.5" x="-68.359375" ry="0" rx="0" style="" class="basic label-container"></rect><g transform="translate(-60.859375, -9)" style="" class="label"><rect></rect><foreignObject height="18" width="121.71875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">更新 accessToken 并重试</span></div></foreignObject></g></g><g transform="translate(809.15625, 215.4375)" data-id="K" data-node="true" id="flowchart-K-11" class="node default default flowchart-label"><rect height="33" width="44.21875" y="-16.5" x="-22.109375" ry="0" rx="0" style="" class="basic label-container"></rect><g transform="translate(-14.609375, -9)" style="" class="label"><rect></rect><foreignObject height="18" width="29.21875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">跳转登录页</span></div></foreignObject></g></g><g transform="translate(365.3046875, 290.74609375)" data-id="L" data-node="true" id="flowchart-L-15" class="node default default flowchart-label"><rect height="33" width="38.375" y="-16.5" x="-19.1875" ry="0" rx="0" style="" class="basic label-container"></rect><g transform="translate(-11.6875, -9)" style="" class="label"><rect></rect><foreignObject height="18" width="23.375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">正常处理</span></div></foreignObject></g></g></g></g></g></g></g></svg>
<figure class="highlight ts"><figcaption><span>api.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> api = axios.<span class="title function_">create</span>({</span><br><span class="line"> <span class="attr">baseURL</span>: process.<span class="property">env</span>.<span class="property">REACT_APP_API_URL</span>,</span><br><span class="line"> <span class="attr">withCredentials</span>: <span class="literal">true</span>,</span><br><span class="line"> <span class="attr">timeout</span>: <span class="number">10000</span></span><br><span class="line">});</span><br><span class="line"></span><br><span class="line"><span class="comment">// 请求拦截器:添加令牌</span></span><br><span class="line">api.<span class="property">interceptors</span>.<span class="property">request</span>.<span class="title function_">use</span>(</span><br><span class="line"> <span class="function">(<span class="params">config</span>) =></span> {</span><br><span class="line"> <span class="keyword">const</span> token = <span class="variable language_">localStorage</span>.<span class="title function_">getItem</span>(<span class="string">'accessToken'</span>);</span><br><span class="line"> <span class="keyword">if</span> (token) {</span><br><span class="line"> config.<span class="property">headers</span> = <span class="title class_">AxiosHeaders</span>.<span class="title function_">from</span>(config.<span class="property">headers</span>);</span><br><span class="line"> config.<span class="property">headers</span>.<span class="title function_">set</span>(<span class="string">'Authorization'</span>, <span class="string">`Bearer <span class="subst">${token}</span>`</span>);</span><br><span class="line"> }</span><br><span class="line"> <span class="keyword">return</span> config;</span><br><span class="line"> },</span><br><span class="line"> <span class="function">(<span class="params">error</span>) =></span> <span class="title class_">Promise</span>.<span class="title function_">reject</span>(error)</span><br><span class="line">);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 响应拦截器:处理令牌刷新</span></span><br><span class="line">api.<span class="property">interceptors</span>.<span class="property">response</span>.<span class="title function_">use</span>(</span><br><span class="line"> <span class="function">(<span class="params">response</span>) =></span> response,</span><br><span class="line"> <span class="title function_">async</span> (error) => {</span><br><span class="line"> <span class="keyword">const</span> originalRequest = error.<span class="property">config</span>;</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 处理 401 错误和令牌刷新</span></span><br><span class="line"> <span class="keyword">if</span> (error.<span class="property">response</span>?.<span class="property">status</span> === <span class="number">401</span> && !originalRequest.<span class="property">_retry</span>) {</span><br><span class="line"> originalRequest.<span class="property">_retry</span> = <span class="literal">true</span>;</span><br><span class="line"> <span class="keyword">const</span> refreshToken = <span class="variable language_">localStorage</span>.<span class="title function_">getItem</span>(<span class="string">'refreshToken'</span>);</span><br><span class="line"> </span><br><span class="line"> <span class="keyword">if</span> (refreshToken) {</span><br><span class="line"> <span class="keyword">try</span> {</span><br><span class="line"> <span class="keyword">const</span> { data } = <span class="keyword">await</span> axios.<span class="title function_">post</span>(</span><br><span class="line"> <span class="string">`<span class="subst">${process.env.REACT_APP_API_URL}</span>/auth/refresh`</span>,</span><br><span class="line"> { refreshToken }</span><br><span class="line"> );</span><br><span class="line"> <span class="keyword">const</span> newAccessToken = data.<span class="property">access_token</span>;</span><br><span class="line"> <span class="variable language_">localStorage</span>.<span class="title function_">setItem</span>(<span class="string">'accessToken'</span>, newAccessToken);</span><br><span class="line"> originalRequest.<span class="property">headers</span>.<span class="property">Authorization</span> = <span class="string">`Bearer <span class="subst">${newAccessToken}</span>`</span>;</span><br><span class="line"> <span class="keyword">return</span> <span class="title function_">api</span>(originalRequest);</span><br><span class="line"> } <span class="keyword">catch</span> (error) {</span><br><span class="line"> <span class="comment">// 刷新失败时清除所有令牌</span></span><br><span class="line"> <span class="variable language_">localStorage</span>.<span class="title function_">removeItem</span>(<span class="string">'accessToken'</span>);</span><br><span class="line"> <span class="variable language_">localStorage</span>.<span class="title function_">removeItem</span>(<span class="string">'refreshToken'</span>);</span><br><span class="line"> <span class="keyword">return</span> <span class="title class_">Promise</span>.<span class="title function_">reject</span>(error);</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"> <span class="keyword">return</span> <span class="title class_">Promise</span>.<span class="title function_">reject</span>(error);</span><br><span class="line"> }</span><br><span class="line">);</span><br></pre></td></tr></tbody></table></figure>
<p>这个改造实现了对身份验证流程的优化和自动化:</p>
<ol>
<li>能够自动为请求添加访问令牌,确保每次请求都具备正确的身份验证信息。</li>
<li>能够检测令牌是否过期,并在必要时进行处理,防止因过期导致请求失败。</li>
<li>支持自动刷新令牌,并在刷新成功后重新尝试原始请求,从而提高用户体验,减少因身份验证失效带来的干扰。</li>
</ol>
<h4 id="442-状态管理优化"><a class="markdownIt-Anchor" href="#442-状态管理优化"></a> 4.4.2. 状态管理优化</h4>
<p>首先先介绍一下身份验证流程中整个认证状态的流转:</p>
<svg aria-roledescription="stateDiagram" role="graphics-document document" viewBox="0 0 281.8671875 416" style="max-width: 281.8671875px;" class="statediagram" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="100%" id="mermaid-1771490045157"><style>#mermaid-1771490045157{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-1771490045157 .error-icon{fill:#552222;}#mermaid-1771490045157 .error-text{fill:#552222;stroke:#552222;}#mermaid-1771490045157 .edge-thickness-normal{stroke-width:2px;}#mermaid-1771490045157 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-1771490045157 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-1771490045157 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-1771490045157 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-1771490045157 .marker{fill:#333333;stroke:#333333;}#mermaid-1771490045157 .marker.cross{stroke:#333333;}#mermaid-1771490045157 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-1771490045157 defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-1771490045157 g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-1771490045157 g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-1771490045157 g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-1771490045157 g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-1771490045157 g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-1771490045157 .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-1771490045157 .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-1771490045157 .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-1771490045157 .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-1771490045157 .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-1771490045157 .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-1771490045157 .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-1771490045157 .edgeLabel .label text{fill:#333;}#mermaid-1771490045157 .label div .edgeLabel{color:#333;}#mermaid-1771490045157 .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-1771490045157 .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-1771490045157 .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-1771490045157 .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-1771490045157 .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-1771490045157 .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-1771490045157 .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-1771490045157 #statediagram-barbEnd{fill:#333333;}#mermaid-1771490045157 .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-1771490045157 .cluster-label,#mermaid-1771490045157 .nodeLabel{color:#131300;}#mermaid-1771490045157 .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-1771490045157 .statediagram-state .divider{stroke:#9370DB;}#mermaid-1771490045157 .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-1771490045157 .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-1771490045157 .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-1771490045157 .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-1771490045157 .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-1771490045157 .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-1771490045157 .note-edge{stroke-dasharray:5;}#mermaid-1771490045157 .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-1771490045157 .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-1771490045157 .statediagram-note text{fill:black;}#mermaid-1771490045157 .statediagram-note .nodeLabel{color:black;}#mermaid-1771490045157 .statediagram .edgeLabel{color:red;}#mermaid-1771490045157 #dependencyStart,#mermaid-1771490045157 #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-1771490045157 .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-1771490045157 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}</style><g><defs><marker orient="auto" markerUnits="strokeWidth" markerHeight="14" markerWidth="20" refY="7" refX="19" id="mermaid-1771490045157_statediagram-barbEnd"><path d="M 19,7 L9,13 L14,7 L9,1 Z"></path></marker></defs><g class="root"><g class="clusters"></g><g class="edgePaths"><path marker-end="url(#mermaid-1771490045157_statediagram-barbEnd)" style="fill:none" class="edge-thickness-normal transition" id="edge0" d="M145.828,22L145.828,26.167C145.828,30.333,145.828,38.667,145.828,47C145.828,55.333,145.828,63.667,145.828,67.833L145.828,72"></path><path marker-end="url(#mermaid-1771490045157_statediagram-barbEnd)" style="fill:none" class="edge-thickness-normal transition" id="edge1" d="M136.839,105L133.752,110.667C130.665,116.333,124.491,127.667,124.491,139C124.491,150.333,130.665,161.667,133.752,167.333L136.839,173"></path><path marker-end="url(#mermaid-1771490045157_statediagram-barbEnd)" style="fill:none" class="edge-thickness-normal transition" id="edge2" d="M145.828,206L145.828,211.667C145.828,217.333,145.828,228.667,145.828,240C145.828,251.333,145.828,262.667,145.828,268.333L145.828,274"></path><path marker-end="url(#mermaid-1771490045157_statediagram-barbEnd)" style="fill:none" class="edge-thickness-normal transition" id="edge3" d="M162.094,178.084L171.375,171.57C180.656,165.056,199.219,152.028,199.219,139C199.219,125.972,180.656,112.944,171.375,106.43L162.094,99.916"></path><path marker-end="url(#mermaid-1771490045157_statediagram-barbEnd)" style="fill:none" class="edge-thickness-normal transition" id="edge4" d="M129.563,297.328L112.224,304.607C94.885,311.885,60.208,326.443,44.973,339.388C29.738,352.333,33.945,363.667,36.049,369.333L38.152,375"></path><path marker-end="url(#mermaid-1771490045157_statediagram-barbEnd)" style="fill:none" class="edge-thickness-normal transition" id="edge5" d="M145.828,307L145.828,312.667C145.828,318.333,145.828,329.667,154.622,341.265C163.417,352.863,181.005,364.725,189.799,370.657L198.594,376.588"></path><path marker-end="url(#mermaid-1771490045157_statediagram-barbEnd)" style="fill:none" class="edge-thickness-normal transition" id="edge6" d="M223.902,375L225,369.333C226.098,363.667,228.295,352.333,217.994,339.867C207.693,327.401,184.893,313.801,173.493,307.002L162.094,300.202"></path><path marker-end="url(#mermaid-1771490045157_statediagram-barbEnd)" style="fill:none" class="edge-thickness-normal transition" id="edge7" d="M234.255,375L238.909,369.333C243.563,363.667,252.871,352.333,257.526,338.25C262.18,324.167,262.18,307.333,262.18,290.5C262.18,273.667,262.18,256.833,262.18,240C262.18,223.167,262.18,206.333,262.18,189.5C262.18,172.667,262.18,155.833,245.499,140.177C228.818,124.52,195.456,110.04,178.775,102.8L162.094,95.56"></path><path marker-end="url(#mermaid-1771490045157_statediagram-barbEnd)" style="fill:none" class="edge-thickness-normal transition" id="edge8" d="M50.415,375L52.523,369.333C54.631,363.667,58.847,352.333,60.955,338.25C63.063,324.167,63.063,307.333,63.063,290.5C63.063,273.667,63.063,256.833,63.063,240C63.063,223.167,63.063,206.333,63.063,189.5C63.063,172.667,63.063,155.833,74.146,140.654C85.229,125.475,107.396,111.95,118.479,105.187L129.563,98.425"></path></g><g class="edgeLabels"><g class="edgeLabel"><g transform="translate(0, 0)" class="label"><rect height="0" width="0" ry="0" rx="0"></rect><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"></span></div></foreignObject></g></g><g transform="translate(118.31640625, 139)" class="edgeLabel"><g transform="translate(-17.53125, -9)" class="label"><rect height="18" width="35.0625" ry="0" rx="0"></rect><foreignObject height="18" width="35.0625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">自动登录检查</span></div></foreignObject></g></g><g transform="translate(145.828125, 240)" class="edgeLabel"><g transform="translate(-17.53125, -9)" class="label"><rect height="18" width="35.0625" ry="0" rx="0"></rect><foreignObject height="18" width="35.0625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">自动登录成功</span></div></foreignObject></g></g><g transform="translate(217.78125, 139)" class="edgeLabel"><g transform="translate(-17.53125, -9)" class="label"><rect height="18" width="35.0625" ry="0" rx="0"></rect><foreignObject height="18" width="35.0625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">自动登录失败</span></div></foreignObject></g></g><g transform="translate(25.53125, 341)" class="edgeLabel"><g transform="translate(-17.53125, -9)" class="label"><rect height="18" width="35.0625" ry="0" rx="0"></rect><foreignObject height="18" width="35.0625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">用户主动登出</span></div></foreignObject></g></g><g transform="translate(145.828125, 341)" class="edgeLabel"><g transform="translate(-52.9765625, -9)" class="label"><rect height="18" width="105.953125" ry="0" rx="0"></rect><foreignObject height="18" width="105.953125"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"> AccessToken 过期</span></div></foreignObject></g></g><g transform="translate(230.4921875, 341)" class="edgeLabel"><g transform="translate(-11.6875, -9)" class="label"><rect height="18" width="23.375" ry="0" rx="0"></rect><foreignObject height="18" width="23.375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">刷新成功</span></div></foreignObject></g></g><g transform="translate(262.1796875, 240)" class="edgeLabel"><g transform="translate(-11.6875, -9)" class="label"><rect height="18" width="23.375" ry="0" rx="0"></rect><foreignObject height="18" width="23.375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">刷新失败</span></div></foreignObject></g></g><g transform="translate(63.0625, 240)" class="edgeLabel"><g transform="translate(-17.53125, -9)" class="label"><rect height="18" width="35.0625" ry="0" rx="0"></rect><foreignObject height="18" width="35.0625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">清除本地存储</span></div></foreignObject></g></g></g><g class="nodes"><g transform="translate(145.828125, 15)" data-id="root_start" data-node="true" id="state-root_start-0" class="node default"><circle height="14" width="14" r="7" class="state-start"></circle></g><g transform="translate(145.828125, 88.5)" data-id="未认证" data-node="true" id="state-未认证-8" class="node statediagram-state undefined"><rect height="33" width="32.53125" y="-16.5" x="-16.265625" style="" class="basic label-container"></rect><g transform="translate(-8.765625, -9)" style="" class="label"><rect></rect><foreignObject height="18" width="17.53125"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">未认证</span></div></foreignObject></g></g><g transform="translate(145.828125, 189.5)" data-id="认证中" data-node="true" id="state-认证中-3" class="node statediagram-state undefined"><rect height="33" width="32.53125" y="-16.5" x="-16.265625" style="" class="basic label-container"></rect><g transform="translate(-8.765625, -9)" style="" class="label"><rect></rect><foreignObject height="18" width="17.53125"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">认证中</span></div></foreignObject></g></g><g transform="translate(145.828125, 290.5)" data-id="已认证" data-node="true" id="state-已认证-6" class="node statediagram-state undefined"><rect height="33" width="32.53125" y="-16.5" x="-16.265625" style="" class="basic label-container"></rect><g transform="translate(-8.765625, -9)" style="" class="label"><rect></rect><foreignObject height="18" width="17.53125"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">已认证</span></div></foreignObject></g></g><g transform="translate(44.27734375, 391.5)" data-id="已注销" data-node="true" id="state-已注销-8" class="node statediagram-state undefined"><rect height="33" width="32.53125" y="-16.5" x="-16.265625" style="" class="basic label-container"></rect><g transform="translate(-8.765625, -9)" style="" class="label"><rect></rect><foreignObject height="18" width="17.53125"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">已注销</span></div></foreignObject></g></g><g transform="translate(220.703125, 391.5)" data-id="令牌刷新中" data-node="true" id="state-令牌刷新中-7" class="node statediagram-state undefined"><rect height="33" width="44.21875" y="-16.5" x="-22.109375" style="" class="basic label-container"></rect><g transform="translate(-14.609375, -9)" style="" class="label"><rect></rect><foreignObject height="18" width="29.21875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">令牌刷新中</span></div></foreignObject></g></g></g></g></g></svg>
<ol>
<li>用户在首次访问时可能处于 <strong>未认证</strong> 状态,系统会自动检查用户的登录状态。</li>
<li>认证过程中可能会发生令牌过期的情况,需要进行 <strong>令牌刷新</strong>。</li>
<li>而在用户注销时,系统会清除本地存储数据。</li>
</ol>
<p>为了有效管理用户的认证状态和流程,我们引入了一个状态机,覆盖以下几种主要状态和转变:</p>
<ol>
<li>
<p>未认证</p>
<p>初始状态,表示用户尚未通过认证。系统会自动检查是否有有效的认证信息(如 <code>accessToken</code> 或 <code>refreshToken</code>)。如果没有,用户将无法访问受保护的资源。</p>
</li>
<li>
<p>认证中</p>
<p>系统正在验证用户的认证信息,可能是通过自动登录检查(例如检查本地存储的 <code>accessToken</code> 或 <code>refreshToken</code>)来恢复用户的会话。若检查成功,用户进入 已认证 状态;若失败,进入 <strong>未认证</strong> 状态。</p>
</li>
<li>
<p>已认证</p>
<p>认证成功后,用户的身份验证信息有效,允许访问受保护的资源。如果在该状态下,用户的 <code>accessToken</code> 过期,系统会自动尝试通过刷新令牌(<code>refreshToken</code>)来更新 <code>accessToken</code>,进入 <strong>令牌刷新中</strong> 状态。</p>
</li>
<li>
<p>令牌刷新中</p>
<p>当 <code>accessToken</code> 过期且用户依然处于已认证状态时,系统会通过 <code>refreshToken</code> 尝试获取新的 <code>accessToken</code>。如果刷新成功,系统返回 <strong>已认证</strong> 状态;如果刷新失败,用户会被迫重新认证(进入 <strong>未认证</strong> 状态)。</p>
</li>
<li>
<p>已注销</p>
<p>用户主动退出时,系统会清除本地存储的认证信息,返回到 <strong>未认证</strong> 状态,确保用户的会话被完全销毁。</p>
</li>
<li>
<p>状态转换</p>
<p>通过自动登录检查、令牌刷新等机制,我们确保用户认证状态的持续性和稳定性。系统根据认证状态的变化自动调整访问权限和用户会话管理。</p>
</li>
</ol>
<p>在用户状态管理中,我们先添加了自动登录相关的功能:</p>
<figure class="highlight ts"><figcaption><span>actions.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> <span class="attr">createUserSlice</span>: <span class="title class_">StateCreator</span><<span class="title class_">UserState</span>> = <span class="function">(<span class="params">set, get</span>) =></span> ({</span><br><span class="line"> <span class="comment">// ... 其他状态</span></span><br><span class="line"> <span class="attr">isAutoLoading</span>: <span class="literal">false</span>, <span class="comment">// 新增自动登录状态</span></span><br><span class="line"></span><br><span class="line"> <span class="comment">// 清除用户信息</span></span><br><span class="line"> <span class="attr">clearUser</span>: <span class="function">() =></span> {</span><br><span class="line"> <span class="variable language_">localStorage</span>.<span class="title function_">removeItem</span>(<span class="string">'accessToken'</span>);</span><br><span class="line"> <span class="variable language_">localStorage</span>.<span class="title function_">removeItem</span>(<span class="string">'refreshToken'</span>);</span><br><span class="line"> <span class="title function_">set</span>({ <span class="attr">user</span>: <span class="literal">null</span>, <span class="attr">isAutoLoading</span>: <span class="literal">false</span> });</span><br><span class="line"> },</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 自动登录功能</span></span><br><span class="line"> <span class="attr">autoLogin</span>: <span class="title function_">async</span> () => {</span><br><span class="line"> <span class="keyword">const</span> state = <span class="title function_">get</span>();</span><br><span class="line"> <span class="keyword">if</span> (state.<span class="property">isAutoLoading</span> || state.<span class="property">user</span>) <span class="keyword">return</span>;</span><br><span class="line"></span><br><span class="line"> <span class="keyword">const</span> accessToken = <span class="variable language_">localStorage</span>.<span class="title function_">getItem</span>(<span class="string">'accessToken'</span>);</span><br><span class="line"> <span class="keyword">const</span> refreshToken = <span class="variable language_">localStorage</span>.<span class="title function_">getItem</span>(<span class="string">'refreshToken'</span>);</span><br><span class="line"></span><br><span class="line"> <span class="keyword">if</span> (!accessToken && !refreshToken) <span class="keyword">return</span>;</span><br><span class="line"></span><br><span class="line"> <span class="title function_">set</span>({ <span class="attr">isAutoLoading</span>: <span class="literal">true</span> });</span><br><span class="line"></span><br><span class="line"> <span class="keyword">try</span> {</span><br><span class="line"> <span class="keyword">let</span> token = accessToken;</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 如果没有访问令牌但有刷新令牌,尝试刷新</span></span><br><span class="line"> <span class="keyword">if</span> (!token && refreshToken) {</span><br><span class="line"> <span class="keyword">const</span> response = <span class="keyword">await</span> api.<span class="title function_">post</span>(<span class="string">'/auth/refresh'</span>, { refreshToken });</span><br><span class="line"> token = response.<span class="property">data</span>.<span class="property">access_token</span>;</span><br><span class="line"> <span class="keyword">if</span> (token) {</span><br><span class="line"> <span class="variable language_">localStorage</span>.<span class="title function_">setItem</span>(<span class="string">'accessToken'</span>, token);</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 获取用户信息</span></span><br><span class="line"> <span class="keyword">const</span> response = <span class="keyword">await</span> api.<span class="title function_">get</span>(<span class="string">'/auth/me'</span>, {</span><br><span class="line"> <span class="attr">headers</span>: { <span class="title class_">Authorization</span>: <span class="string">`Bearer <span class="subst">${token}</span>`</span> }</span><br><span class="line"> });</span><br><span class="line"></span><br><span class="line"> <span class="title function_">set</span>({ <span class="attr">user</span>: response.<span class="property">data</span>, <span class="attr">isAutoLoading</span>: <span class="literal">false</span> });</span><br><span class="line"> } <span class="keyword">catch</span> (error) {</span><br><span class="line"> <span class="variable language_">localStorage</span>.<span class="title function_">removeItem</span>(<span class="string">'accessToken'</span>);</span><br><span class="line"> <span class="variable language_">localStorage</span>.<span class="title function_">removeItem</span>(<span class="string">'refreshToken'</span>);</span><br><span class="line"> <span class="title function_">set</span>({ <span class="attr">user</span>: <span class="literal">null</span>, <span class="attr">isAutoLoading</span>: <span class="literal">false</span> });</span><br><span class="line"> <span class="keyword">throw</span> error;</span><br><span class="line"> }</span><br><span class="line"> },</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 登出功能增强</span></span><br><span class="line"> <span class="attr">logout</span>: <span class="title function_">async</span> () => {</span><br><span class="line"> <span class="keyword">try</span> {</span><br><span class="line"> <span class="keyword">await</span> api.<span class="title function_">post</span>(<span class="string">'/auth/logout'</span>);</span><br><span class="line"> } <span class="keyword">finally</span> {</span><br><span class="line"> <span class="variable language_">localStorage</span>.<span class="title function_">removeItem</span>(<span class="string">'accessToken'</span>);</span><br><span class="line"> <span class="variable language_">localStorage</span>.<span class="title function_">removeItem</span>(<span class="string">'refreshToken'</span>);</span><br><span class="line"> <span class="title function_">set</span>({ <span class="attr">user</span>: <span class="literal">null</span> });</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line">});</span><br></pre></td></tr></tbody></table></figure>
<h4 id="443-状态管理范式转变"><a class="markdownIt-Anchor" href="#443-状态管理范式转变"></a> 4.4.3. 状态管理范式转变</h4>
<p>在登录功能中,我们需要处理用户的身份验证,同时提供「记住我」功能。然而,使用传统的状态管理(也就是我们的 Zustand)来存储 <code>error</code> 和 <code>isLoading</code> 状态可能会带来一些问题:</p>
<ul>
<li>全局状态污染:<code>error</code> 和 <code>isLoading</code> 这些状态只是登录相关,不需要在整个应用范围内存储。</li>
<li>手动管理状态:需要在 <code>try/catch</code> 中手动更新 <code>isLoading</code>,并在发生错误时手动存储 <code>error</code>,代码较为冗长。</li>
<li>状态同步问题:如果多个组件依赖相同的登录逻辑,手动管理状态可能会导致数据不同步。</li>
</ul>
<p>为了解决这些问题,我们决定使用 React Query 提供的 <code>useMutation</code>,将登录请求的状态管理完全交由 React Query 处理。</p>
<p>安装 React Query:</p>
<figure class="highlight bash"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">yarn add @tanstack/react-query</span><br></pre></td></tr></tbody></table></figure>
<p>让我们以注册组件为例,来看看这次改造。</p>
<p>在之前的实现中,我们使用全局状态来管理加载状态和错误信息:</p>
<figure class="highlight tsx"><figcaption><span>Register.tsx</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> <span class="title function_">Register</span> = (<span class="params"></span>) => {</span><br><span class="line"> <span class="keyword">const</span> register = <span class="title function_">useUserStore</span>(<span class="function"><span class="params">state</span> =></span> state.<span class="property">register</span>);</span><br><span class="line"> <span class="keyword">const</span> isLoading = <span class="title function_">useUserStore</span>(<span class="function"><span class="params">state</span> =></span> state.<span class="property">isLoading</span>);</span><br><span class="line"> <span class="keyword">const</span> error = <span class="title function_">useUserStore</span>(<span class="function"><span class="params">state</span> =></span> state.<span class="property">error</span>);</span><br><span class="line"></span><br><span class="line"> <span class="keyword">const</span> formik = <span class="title function_">useFormik</span>({</span><br><span class="line"> <span class="comment">// ...</span></span><br><span class="line"> <span class="attr">onSubmit</span>: <span class="title function_">async</span> (values) => {</span><br><span class="line"> <span class="keyword">try</span> {</span><br><span class="line"> <span class="keyword">const</span> { username, email, password } = values;</span><br><span class="line"> <span class="keyword">await</span> <span class="title function_">register</span>({ username, email, password });</span><br><span class="line"> <span class="title function_">navigate</span>(<span class="string">'/login'</span>, {</span><br><span class="line"> <span class="attr">state</span>: {</span><br><span class="line"> <span class="attr">message</span>: <span class="string">'注册成功!请登录您的账号。'</span>,</span><br><span class="line"> <span class="attr">email</span>: values.<span class="property">email</span></span><br><span class="line"> }</span><br><span class="line"> });</span><br><span class="line"> } <span class="keyword">catch</span> (error) {</span><br><span class="line"> <span class="variable language_">console</span>.<span class="title function_">error</span>(<span class="string">'注册失败:'</span>, error);</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"> });</span><br><span class="line">};</span><br></pre></td></tr></tbody></table></figure>
<p>这种方式存在以下问题:</p>
<ul>
<li>状态管理过于集中,不同组件的加载状态和错误状态混杂在一起</li>
<li>需要手动管理状态的清理和重置</li>
<li>错误处理不够优雅</li>
<li>缺乏对请求生命周期的完整控制</li>
</ul>
<p>在开始实现具体功能之前,我们需要先配置 React Query。首先修改应用的入口文件 <code>index.tsx</code>:</p>
<figure class="highlight tsx"><figcaption><span>index.tsx</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> { <span class="title class_">QueryClient</span>, <span class="title class_">QueryClientProvider</span> } <span class="keyword">from</span> <span class="string">'@tanstack/react-query'</span>;</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> queryClient = <span class="keyword">new</span> <span class="title class_">QueryClient</span>();</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> root = <span class="title class_">ReactDOM</span>.<span class="title function_">createRoot</span>(<span class="variable language_">document</span>.<span class="title function_">getElementById</span>(<span class="string">'root'</span>) <span class="keyword">as</span> <span class="title class_">HTMLElement</span>);</span><br><span class="line">root.<span class="title function_">render</span>(</span><br><span class="line"> <span class="language-xml"><span class="tag"><<span class="name">React.StrictMode</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">QueryClientProvider</span> <span class="attr">client</span>=<span class="string">{queryClient}</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">App</span> /></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">QueryClientProvider</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">React.StrictMode</span>></span></span></span><br><span class="line">);</span><br></pre></td></tr></tbody></table></figure>
<p>使用 React Query 的 <code>useMutation</code> 后,代码变得更加清晰和强大:</p>
<figure class="highlight tsx"><figcaption><span>Register.tsx</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> <span class="title function_">Register</span> = (<span class="params"></span>) => {</span><br><span class="line"> <span class="keyword">const</span> setUser = <span class="title function_">useUserStore</span>(<span class="function"><span class="params">state</span> =></span> state.<span class="property">setUser</span>);</span><br><span class="line"></span><br><span class="line"> <span class="keyword">const</span> { <span class="attr">mutate</span>: register, isPending, error } = <span class="title function_">useMutation</span>({</span><br><span class="line"> <span class="attr">mutationFn</span>: <span class="title function_">async</span> (<span class="attr">values</span>: {</span><br><span class="line"> <span class="attr">username</span>: <span class="built_in">string</span>;</span><br><span class="line"> <span class="attr">email</span>: <span class="built_in">string</span>;</span><br><span class="line"> <span class="attr">password</span>: <span class="built_in">string</span>;</span><br><span class="line"> }) => {</span><br><span class="line"> <span class="keyword">const</span> response = <span class="keyword">await</span> api.<span class="title function_">post</span>(<span class="string">'/users/create'</span>, values);</span><br><span class="line"> <span class="keyword">return</span> response.<span class="property">data</span>;</span><br><span class="line"> },</span><br><span class="line"> <span class="attr">onSuccess</span>: <span class="function">(<span class="params">data</span>) =></span> {</span><br><span class="line"> <span class="title function_">setUser</span>(data.<span class="property">user</span>);</span><br><span class="line"> <span class="title function_">navigate</span>(<span class="string">'/login'</span>, {</span><br><span class="line"> <span class="attr">state</span>: {</span><br><span class="line"> <span class="attr">message</span>: <span class="string">'注册成功!请检查邮箱完成验证'</span>,</span><br><span class="line"> <span class="attr">email</span>: data.<span class="property">user</span>.<span class="property">email</span></span><br><span class="line"> },</span><br><span class="line"> <span class="attr">replace</span>: <span class="literal">true</span></span><br><span class="line"> });</span><br><span class="line"> }</span><br><span class="line"> });</span><br><span class="line"></span><br><span class="line"> <span class="keyword">const</span> formik = <span class="title function_">useFormik</span>({</span><br><span class="line"> <span class="comment">// ...</span></span><br><span class="line"> <span class="attr">onSubmit</span>: <span class="title function_">async</span> (values) => {</span><br><span class="line"> <span class="title function_">register</span>({</span><br><span class="line"> <span class="attr">username</span>: values.<span class="property">username</span>,</span><br><span class="line"> <span class="attr">email</span>: values.<span class="property">email</span>,</span><br><span class="line"> <span class="attr">password</span>: values.<span class="property">password</span></span><br><span class="line"> });</span><br><span class="line"> }</span><br><span class="line"> });</span><br><span class="line">};</span><br></pre></td></tr></tbody></table></figure>
<p>这种改进带来了多个显著的好处,使得代码更加清晰、组件更加独立,且状态管理更加高效:</p>
<ol>
<li>
<p>实现了状态隔离。每个组件都有自己独立的加载和错误状态,不同的请求不会相互影响,从而避免了状态混乱的问题。同时,当组件卸载时,状态会自动清理,确保资源的合理释放。</p>
</li>
<li>
<p>得益于生命周期钩子的引入,异步操作的管理更加灵活。<code>mutationFn</code> 负责定义实际的异步操作,<code>onSuccess</code> 处理成功场景,而 <code>onError</code> 则用于错误处理。这些钩子让我们能够更精准地控制请求的执行过程。</p>
<p>在错误处理方面,这种改进也带来了增强。错误信息会被自动捕获并保存,同时错误状态与组件绑定,使得错误管理更加直观。此外,还能够提供更详细的错误类型信息,方便开发者调试和优化。</p>
</li>
<li>
<p>大幅简化了使用方式。开发者不需要手动处理 <code>try/catch</code> 逻辑,也不需要手动管理 <code>loading</code> 状态,只需直接调用 <code>mutate</code> 函数,即可完成异步操作,提高了开发效率和代码可读性。</p>
</li>
</ol>
<blockquote>
<p>使用 <code>useMutation</code> 的基本步骤:</p>
<ol>
<li>定义 <code>mutation</code> 函数:</li>
</ol>
<figure class="highlight ts"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">mutationFn</span>: <span class="title function_">async</span> (values) => {</span><br><span class="line"> <span class="keyword">const</span> response = <span class="keyword">await</span> api.<span class="title function_">post</span>(<span class="string">'/users/create'</span>, values);</span><br><span class="line"> <span class="keyword">return</span> response.<span class="property">data</span>;</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
<ol start="2">
<li>配置生命周期钩子:</li>
</ol>
<figure class="highlight ts"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">onSuccess</span>: <span class="function">(<span class="params">data</span>) =></span> {</span><br><span class="line"> <span class="comment">// 处理成功场景</span></span><br><span class="line">},</span><br><span class="line"><span class="attr">onError</span>: <span class="function">(<span class="params">error</span>) =></span> {</span><br><span class="line"> <span class="comment">// 处理错误场景</span></span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
<ol start="3">
<li>在组件中使用:</li>
</ol>
<figure class="highlight ts"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> { mutate, isPending, error } = <span class="title function_">useMutation</span>({ ... });</span><br><span class="line"></span><br><span class="line"><span class="comment">// 触发 mutation</span></span><br><span class="line"><span class="title function_">mutate</span>(values);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 使用状态</span></span><br><span class="line">{isPending && <span class="language-xml"><span class="tag"><<span class="name">LoadingSpinner</span> /></span></span>}</span><br><span class="line">{error && <span class="language-xml"><span class="tag"><<span class="name">ErrorMessage</span> <span class="attr">error</span>=<span class="string">{error}</span> /></span></span>}</span><br></pre></td></tr></tbody></table></figure>
</blockquote>
<p>同样的,修改 <code>VerifyEmail</code> 组件:</p>
<figure class="highlight tsx"><figcaption><span>VerifyEmail.tsx</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br><span class="line">83</span><br><span class="line">84</span><br><span class="line">85</span><br><span class="line">86</span><br><span class="line">87</span><br><span class="line">88</span><br><span class="line">89</span><br><span class="line">90</span><br><span class="line">91</span><br><span class="line">92</span><br><span class="line">93</span><br><span class="line">94</span><br><span class="line">95</span><br><span class="line">96</span><br><span class="line">97</span><br><span class="line">98</span><br><span class="line">99</span><br><span class="line">100</span><br><span class="line">101</span><br><span class="line">102</span><br><span class="line">103</span><br><span class="line">104</span><br><span class="line">105</span><br><span class="line">106</span><br><span class="line">107</span><br><span class="line">108</span><br><span class="line">109</span><br><span class="line">110</span><br><span class="line">111</span><br><span class="line">112</span><br><span class="line">113</span><br><span class="line">114</span><br><span class="line">115</span><br><span class="line">116</span><br><span class="line">117</span><br><span class="line">118</span><br><span class="line">119</span><br><span class="line">120</span><br><span class="line">121</span><br><span class="line">122</span><br><span class="line">123</span><br><span class="line">124</span><br><span class="line">125</span><br><span class="line">126</span><br><span class="line">127</span><br><span class="line">128</span><br><span class="line">129</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> <span class="title class_">React</span>, {useEffect} <span class="keyword">from</span> <span class="string">'react'</span>; </span><br><span class="line"><span class="keyword">import</span> {useLocation, useNavigate} <span class="keyword">from</span> <span class="string">'react-router-dom'</span>; </span><br><span class="line"><span class="keyword">import</span> {useMutation} <span class="keyword">from</span> <span class="string">'@tanstack/react-query'</span>; </span><br><span class="line"><span class="keyword">import</span> {useUserStore} <span class="keyword">from</span> <span class="string">'../stores'</span>; </span><br><span class="line"><span class="keyword">import</span> api <span class="keyword">from</span> <span class="string">'../stores/common/api'</span>; </span><br><span class="line"> </span><br><span class="line"><span class="keyword">const</span> <span class="title function_">VerifyEmail</span> = (<span class="params"></span>) => { </span><br><span class="line"> <span class="keyword">const</span> location = <span class="title function_">useLocation</span>(); </span><br><span class="line"> <span class="keyword">const</span> navigate = <span class="title function_">useNavigate</span>(); </span><br><span class="line"> <span class="keyword">const</span> token = <span class="keyword">new</span> <span class="title class_">URLSearchParams</span>(location.<span class="property">search</span>).<span class="title function_">get</span>(<span class="string">'token'</span>); </span><br><span class="line"> </span><br><span class="line"> <span class="keyword">const</span> { </span><br><span class="line"> emailVerified, </span><br><span class="line"> verificationError, </span><br><span class="line"> verificationToken, </span><br><span class="line"> verificationUserId, </span><br><span class="line"> clearVerificationState </span><br><span class="line"> } = <span class="title function_">useUserStore</span>(<span class="function"><span class="params">state</span> =></span> ({ </span><br><span class="line"> <span class="attr">emailVerified</span>: state.<span class="property">emailVerified</span>, </span><br><span class="line"> <span class="attr">verificationError</span>: state.<span class="property">verificationError</span>, </span><br><span class="line"> <span class="attr">verificationToken</span>: state.<span class="property">verificationToken</span>, </span><br><span class="line"> <span class="attr">verificationUserId</span>: state.<span class="property">verificationUserId</span>, </span><br><span class="line"> <span class="attr">clearVerificationState</span>: state.<span class="property">clearVerificationState</span> </span><br><span class="line"> })); </span><br><span class="line"> </span><br><span class="line"> <span class="keyword">const</span> { <span class="attr">mutate</span>: verifyEmail, isPending } = <span class="title function_">useMutation</span>({ </span><br><span class="line"> <span class="attr">mutationFn</span>: <span class="title function_">async</span> (<span class="attr">token</span>: <span class="built_in">string</span>) => { </span><br><span class="line"> <span class="keyword">const</span> response = <span class="keyword">await</span> api.<span class="title function_">get</span>(<span class="string">`/users/verify-email?token=<span class="subst">${token}</span>`</span>); </span><br><span class="line"> <span class="keyword">return</span> response.<span class="property">data</span>; </span><br><span class="line"> }, </span><br><span class="line"> <span class="attr">onSuccess</span>: <span class="function">(<span class="params">data</span>) =></span> { </span><br><span class="line"> <span class="keyword">if</span> (data.<span class="property">success</span>) { </span><br><span class="line"> useUserStore.<span class="title function_">setState</span>({ </span><br><span class="line"> <span class="attr">emailVerified</span>: <span class="literal">true</span>, </span><br><span class="line"> <span class="attr">verificationUserId</span>: data.<span class="property">userId</span> </span><br><span class="line"> }); </span><br><span class="line"> <span class="title function_">navigate</span>(<span class="string">'/login'</span>, { <span class="attr">replace</span>: <span class="literal">true</span> }) </span><br><span class="line"> } <span class="keyword">else</span> { </span><br><span class="line"> useUserStore.<span class="title function_">setState</span>({ </span><br><span class="line"> <span class="attr">verificationError</span>: data.<span class="property">error</span>, </span><br><span class="line"> <span class="attr">verificationUserId</span>: data.<span class="property">userId</span> </span><br><span class="line"> }); </span><br><span class="line"> } </span><br><span class="line"> }, </span><br><span class="line"> <span class="attr">onError</span>: <span class="function">(<span class="params">error</span>) =></span> { </span><br><span class="line"> useUserStore.<span class="title function_">setState</span>({ <span class="attr">verificationError</span>: error.<span class="property">message</span> || <span class="string">'验证过程中发生未知错误'</span> }); </span><br><span class="line"> } </span><br><span class="line"> }); </span><br><span class="line"> </span><br><span class="line"> <span class="title function_">useEffect</span>(<span class="function">() =></span> { </span><br><span class="line"> <span class="keyword">if</span> (token) <span class="title function_">verifyEmail</span>(token); </span><br><span class="line"> }, [token]); </span><br><span class="line"> </span><br><span class="line"> <span class="title function_">useEffect</span>(<span class="function">() =></span> { </span><br><span class="line"> <span class="keyword">return</span> <span class="function">() =></span> { </span><br><span class="line"> <span class="title function_">clearVerificationState</span>(); </span><br><span class="line"> <span class="variable language_">window</span>.<span class="property">history</span>.<span class="title function_">replaceState</span>({}, <span class="string">''</span>, <span class="variable language_">window</span>.<span class="property">location</span>.<span class="property">pathname</span>); </span><br><span class="line"> } </span><br><span class="line"> }, []); </span><br><span class="line"> </span><br><span class="line"> <span class="keyword">const</span> <span class="title function_">handleResend</span> = <span class="keyword">async</span> (<span class="params"></span>) => { </span><br><span class="line"> <span class="keyword">if</span> (verificationUserId) { </span><br><span class="line"> <span class="keyword">try</span> { </span><br><span class="line"> <span class="keyword">await</span> api.<span class="title function_">post</span>(<span class="string">`/users/resend-verification/<span class="subst">${verificationUserId}</span>`</span>); </span><br><span class="line"> <span class="title function_">navigate</span>(<span class="string">'/login'</span>, { </span><br><span class="line"> <span class="attr">state</span>: { <span class="attr">message</span>: <span class="string">'新的验证邮件已发送,请查收邮箱'</span> } </span><br><span class="line"> }); </span><br><span class="line"> } <span class="keyword">catch</span> (error) { </span><br><span class="line"> useUserStore.<span class="title function_">setState</span>({ <span class="attr">verificationError</span>: <span class="string">'重新发送验证邮件失败'</span> }); </span><br><span class="line"> <span class="keyword">throw</span> error <span class="keyword">instanceof</span> <span class="title class_">Error</span> </span><br><span class="line"> ? error.<span class="property">message</span> </span><br><span class="line"> : <span class="string">'重新发送验证邮件失败'</span>; </span><br><span class="line"> } </span><br><span class="line"> } </span><br><span class="line"> } </span><br><span class="line"> </span><br><span class="line"> <span class="keyword">return</span> ( </span><br><span class="line"> <span class="language-xml"><span class="tag"><<span class="name">div</span> <span class="attr">className</span>=<span class="string">"min-h-screen flex items-center justify-center bg-base-200"</span>></span> </span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">div</span> <span class="attr">className</span>=<span class="string">"card w-96 bg-base-100 shadow-xl"</span>></span> </span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">div</span> <span class="attr">className</span>=<span class="string">"card-body items-center text-center"</span>></span> </span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">h2</span> <span class="attr">className</span>=<span class="string">"card-title mb-4"</span>></span> </span></span><br><span class="line"><span class="language-xml"> 验证电子邮件 </span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">h2</span>></span> </span></span><br><span class="line"><span class="language-xml"> </span></span><br><span class="line"><span class="language-xml"> {isPending && ( </span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">div</span> <span class="attr">className</span>=<span class="string">"flex flex-col items-center gap-4"</span>></span> </span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">span</span> <span class="attr">className</span>=<span class="string">"loading loading-spinner loading-lg"</span> /></span> </span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">p</span>></span>正在验证您的邮箱...<span class="tag"></<span class="name">p</span>></span> </span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">div</span>></span> </span></span><br><span class="line"><span class="language-xml"> )} </span></span><br><span class="line"><span class="language-xml"> </span></span><br><span class="line"><span class="language-xml"> {emailVerified && ( </span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">div</span> <span class="attr">className</span>=<span class="string">"alert alert-success"</span>></span> </span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">span</span>></span>邮箱验证成功!正在跳转至登录页面……<span class="tag"></<span class="name">span</span>></span> </span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">div</span>></span> </span></span><br><span class="line"><span class="language-xml"> )} </span></span><br><span class="line"><span class="language-xml"> </span></span><br><span class="line"><span class="language-xml"> {verificationError && ( </span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">div</span> <span class="attr">className</span>=<span class="string">"alert alert-error flex flex-col gap-3"</span>></span> </span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">span</span>></span>{verificationError}<span class="tag"></<span class="name">span</span>></span> </span></span><br><span class="line"><span class="language-xml"> {verificationUserId && ( </span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">button</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">className</span>=<span class="string">"btn btn-sm btn-outline"</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">onClick</span>=<span class="string">{handleResend}</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> ></span> </span></span><br><span class="line"><span class="language-xml"> 重新发送验证邮件 </span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">button</span>></span> </span></span><br><span class="line"><span class="language-xml"> )} </span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">div</span>></span> </span></span><br><span class="line"><span class="language-xml"> )} </span></span><br><span class="line"><span class="language-xml"> </span></span><br><span class="line"><span class="language-xml"> {!isPending && !emailVerified && !verificationError && verificationToken && ( </span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">div</span> <span class="attr">className</span>=<span class="string">"flex flex-col gap-3"</span>></span> </span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">p</span>></span>验证链接已失效<span class="tag"></<span class="name">p</span>></span> </span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">button</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">className</span>=<span class="string">"btn btn-outline"</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">onClick</span>=<span class="string">{()</span> =></span> verifyEmail(verificationToken)} </span></span><br><span class="line"><span class="language-xml"> > </span></span><br><span class="line"><span class="language-xml"> 重新尝试验证 </span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">button</span>></span> </span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">div</span>></span> </span></span><br><span class="line"><span class="language-xml"> )} </span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">div</span>></span> </span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">div</span>></span> </span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">div</span>></span></span> </span><br><span class="line"> ); </span><br><span class="line">}; </span><br><span class="line"> </span><br><span class="line"><span class="keyword">export</span> <span class="keyword">default</span> <span class="title class_">VerifyEmail</span>; </span><br></pre></td></tr></tbody></table></figure>
<h4 id="444-登录组件改造"><a class="markdownIt-Anchor" href="#444-登录组件改造"></a> 4.4.4. 登录组件改造</h4>
<p>我们需要对登录组件(<code>Login.tsx</code>)进行改造,以支持「记住我」功能。</p>
<ol>
<li>
<p>在表单验证模式中增加「记住我」选项:</p>
<figure class="highlight tsx"><figcaption><span>Login.tsx</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> validationSchema = <span class="title class_">Yup</span>.<span class="title function_">object</span>().<span class="title function_">shape</span>({ </span><br><span class="line"> <span class="attr">username</span>: <span class="title class_">Yup</span>.<span class="title function_">string</span>().<span class="title function_">required</span>(<span class="string">'请输入用户名!'</span>), </span><br><span class="line"> <span class="attr">password</span>: <span class="title class_">Yup</span>.<span class="title function_">string</span>().<span class="title function_">required</span>(<span class="string">'请输入密码!'</span>),</span><br><span class="line"> <span class="attr">rememberMe</span>: <span class="title class_">Yup</span>.<span class="title function_">boolean</span>() <span class="comment">// 新增</span></span><br><span class="line">}); </span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> formik = <span class="title function_">useFormik</span>({ </span><br><span class="line"> <span class="attr">initialValues</span>: { </span><br><span class="line"> <span class="attr">username</span>: <span class="string">''</span>, </span><br><span class="line"> <span class="attr">password</span>: <span class="string">''</span>, </span><br><span class="line"> <span class="attr">rememberMe</span>: <span class="literal">false</span> <span class="comment">// 新增</span></span><br><span class="line"> }, </span><br><span class="line"> validationSchema,</span><br><span class="line"> <span class="attr">onSubmit</span>: <span class="title function_">async</span> (values) => {</span><br><span class="line"> <span class="title function_">login</span>(values);</span><br><span class="line"> }</span><br><span class="line">});</span><br></pre></td></tr></tbody></table></figure>
</li>
<li>
<p>使用 <code>useMutation</code> 重构登录逻辑:</p>
<figure class="highlight tsx"><figcaption><span>Login.tsx</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> { <span class="attr">mutate</span>: login, isPending, error } = <span class="title function_">useMutation</span>({ </span><br><span class="line"> <span class="attr">mutationFn</span>: <span class="title function_">async</span> (<span class="attr">credentials</span>: { </span><br><span class="line"> <span class="attr">username</span>: <span class="built_in">string</span>, </span><br><span class="line"> <span class="attr">password</span>: <span class="built_in">string</span>, </span><br><span class="line"> <span class="attr">rememberMe</span>: <span class="built_in">boolean</span>, </span><br><span class="line"> }) => { </span><br><span class="line"> <span class="keyword">const</span> response = <span class="keyword">await</span> api.<span class="title function_">post</span>(<span class="string">'/auth/login'</span>, credentials); </span><br><span class="line"> <span class="keyword">return</span> response.<span class="property">data</span>; </span><br><span class="line"> }, </span><br><span class="line"> <span class="attr">onSuccess</span>: <span class="title function_">async</span> (data) => { </span><br><span class="line"> <span class="variable language_">localStorage</span>.<span class="title function_">setItem</span>(<span class="string">'accessToken'</span>, data.<span class="property">access_token</span>); </span><br><span class="line"> <span class="comment">// 只有在用户选择“记住我”时才保存刷新令牌</span></span><br><span class="line"> <span class="keyword">if</span> (formik.<span class="property">values</span>.<span class="property">rememberMe</span>) {</span><br><span class="line"> <span class="variable language_">localStorage</span>.<span class="title function_">setItem</span>(<span class="string">'refreshToken'</span>, data.<span class="property">refresh_token</span>); </span><br><span class="line"> }</span><br><span class="line"> <span class="title function_">setUser</span>(data.<span class="property">user</span>); </span><br><span class="line"> <span class="title function_">navigate</span>(<span class="string">'/'</span>, { </span><br><span class="line"> <span class="attr">state</span>: { <span class="attr">message</span>: <span class="string">'登陆成功!'</span> }, </span><br><span class="line"> <span class="attr">replace</span>: <span class="literal">true</span> </span><br><span class="line"> }) </span><br><span class="line"> }, </span><br><span class="line"> <span class="attr">onError</span>: <span class="function">(<span class="params">error</span>) =></span> { </span><br><span class="line"> <span class="comment">// 登录失败时清除所有令牌</span></span><br><span class="line"> <span class="variable language_">localStorage</span>.<span class="title function_">removeItem</span>(<span class="string">'accessToken'</span>); </span><br><span class="line"> <span class="variable language_">localStorage</span>.<span class="title function_">removeItem</span>(<span class="string">'refreshToken'</span>); </span><br><span class="line"> <span class="title function_">clearUser</span>(); </span><br><span class="line"> <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">'登录失败:'</span>, error); </span><br><span class="line"> } </span><br><span class="line">});</span><br></pre></td></tr></tbody></table></figure>
</li>
<li>
<p>添加自动登录检查逻辑:</p>
<figure class="highlight tsx"><figcaption><span>Login.tsx</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">useEffect</span>(<span class="function">() =></span> { </span><br><span class="line"> <span class="keyword">const</span> <span class="title function_">checkAutoLogin</span> = <span class="keyword">async</span> (<span class="params"></span>) => { </span><br><span class="line"> <span class="keyword">const</span> accessToken = <span class="variable language_">localStorage</span>.<span class="title function_">getItem</span>(<span class="string">'accessToken'</span>); </span><br><span class="line"> <span class="keyword">const</span> refreshToken = <span class="variable language_">localStorage</span>.<span class="title function_">getItem</span>(<span class="string">'refreshToken'</span>); </span><br><span class="line"></span><br><span class="line"> <span class="keyword">if</span> (accessToken || refreshToken) { </span><br><span class="line"> <span class="keyword">try</span> { </span><br><span class="line"> <span class="keyword">await</span> <span class="title function_">autoLogin</span>(); </span><br><span class="line"> } <span class="keyword">catch</span> (error) { </span><br><span class="line"> <span class="title function_">clearUser</span>(); </span><br><span class="line"> <span class="variable language_">localStorage</span>.<span class="title function_">removeItem</span>(<span class="string">'accessToken'</span>); </span><br><span class="line"> <span class="variable language_">localStorage</span>.<span class="title function_">removeItem</span>(<span class="string">'refreshToken'</span>); </span><br><span class="line"> <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">'检查自动登录失败:'</span>, error); </span><br><span class="line"> } </span><br><span class="line"> } </span><br><span class="line"> } </span><br><span class="line"></span><br><span class="line"> <span class="title function_">checkAutoLogin</span>().<span class="title function_">then</span>(<span class="function"><span class="params">r</span> =></span> <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">'检查自动登录成功:'</span>, r)); </span><br><span class="line">}, []);</span><br></pre></td></tr></tbody></table></figure>
</li>
<li>
<p>登录后自动跳转:</p>
<figure class="highlight tsx"><figcaption><span>Login.tsx</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">useEffect</span>(<span class="function">() =></span> { </span><br><span class="line"> <span class="keyword">if</span> (user && !isAutoLoading) { </span><br><span class="line"> <span class="title function_">navigate</span>(<span class="string">'/'</span>, { <span class="attr">replace</span>: <span class="literal">true</span> }); </span><br><span class="line"> } </span><br><span class="line">}, [user, isAutoLoading, navigate]);</span><br></pre></td></tr></tbody></table></figure>
</li>
<li>
<p>添加「记住我」选项的界面元素:</p>
<figure class="highlight tsx"><figcaption><span>Login.tsx</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"><div className=<span class="string">"mb-4 flex items-center"</span>> </span><br><span class="line"> <span class="language-xml"><span class="tag"><<span class="name">input</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">type</span>=<span class="string">"checkbox"</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">id</span>=<span class="string">"rememberMe"</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">name</span>=<span class="string">"rememberMe"</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">className</span>=<span class="string">"w-4 h-4 text-primary bg-base-300 border-neutral-600 rounded"</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">checked</span>=<span class="string">{formik.values.rememberMe}</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">onChange</span>=<span class="string">{formik.handleChange}</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">disabled</span>=<span class="string">{isPending}</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> /></span></span> </span><br><span class="line"> <span class="language-xml"><span class="tag"><<span class="name">label</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">htmlFor</span>=<span class="string">"rememberMe"</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">className</span>=<span class="string">"ml-2 text-sm text-neutral-content cursor-pointer"</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> ></span> </span></span><br><span class="line"><span class="language-xml"> 记住我 </span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">label</span>></span></span> </span><br><span class="line"></div></span><br></pre></td></tr></tbody></table></figure>
</li>
</ol>
<h2 id="45-邮箱验证提示"><a class="markdownIt-Anchor" href="#45-邮箱验证提示"></a> 4.5. 邮箱验证提示</h2>
<p>为了提升用户体验,我们需要在用户未验证邮箱时给出明显的提示。这个功能包含两个部分:提示组件和布局改造。</p>
<p>创建 <code>UnverifiedBanner</code> 组件来显示验证提示:</p>
<figure class="highlight tsx"><figcaption><span>UnverifiedBanner.tsx</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> <span class="title class_">React</span> <span class="keyword">from</span> <span class="string">'react'</span>;</span><br><span class="line"><span class="keyword">import</span> { useUserStore } <span class="keyword">from</span> <span class="string">'../stores'</span>;</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> <span class="title function_">UnverifiedBanner</span> = (<span class="params"></span>) => {</span><br><span class="line"> <span class="keyword">const</span> user = <span class="title function_">useUserStore</span>(<span class="function"><span class="params">state</span> =></span> state.<span class="property">user</span>);</span><br><span class="line"> <span class="keyword">if</span> (user && !user.<span class="property">verified</span>) {</span><br><span class="line"> <span class="keyword">return</span> (</span><br><span class="line"> <span class="language-xml"><span class="tag"><<span class="name">div</span> <span class="attr">className</span>=<span class="string">"p-4 mb-4 text-sm text-warning-content bg-warning rounded"</span>></span></span></span><br><span class="line"><span class="language-xml"> 您的邮箱尚未验证,请尽快验证。您可以修改邮箱地址或<span class="tag"><<span class="name">a</span> <span class="attr">href</span>=<span class="string">"/resend-verification"</span> <span class="attr">className</span>=<span class="string">"underline ml-1"</span>></span>重新发送验证邮件<span class="tag"></<span class="name">a</span>></span>。</span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">div</span>></span></span></span><br><span class="line"> );</span><br><span class="line"> }</span><br><span class="line"> <span class="keyword">return</span> <span class="literal">null</span>;</span><br><span class="line">};</span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">default</span> <span class="title class_">UnverifiedBanner</span>;</span><br></pre></td></tr></tbody></table></figure>
<p>这个组件具有几个重要特点。它通过全局状态获取用户信息,确保始终使用最新的用户数据。同时,它只会在用户尚未完成验证时显示,避免对已验证用户造成干扰。此外,组件还提供了快捷的邮箱验证操作,使用户能够方便地完成身份确认,提高使用体验。</p>
<p>将验证提示集成到 <code>AuthLayout</code> 中:</p>
<figure class="highlight tsx"><figcaption><span>AuthLayout.tsx</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> <span class="title class_">React</span> <span class="keyword">from</span> <span class="string">'react'</span>; </span><br><span class="line"><span class="keyword">import</span> <span class="title class_">UnverifiedBanner</span> <span class="keyword">from</span> <span class="string">'../components/UnverifiedBanner'</span>; </span><br><span class="line"> </span><br><span class="line"><span class="keyword">type</span> <span class="title class_">AuthLayoutProps</span> = { </span><br><span class="line"> <span class="attr">children</span>: <span class="title class_">React</span>.<span class="property">ReactNode</span>; </span><br><span class="line">}; </span><br><span class="line"> </span><br><span class="line"><span class="keyword">const</span> <span class="title function_">AuthLayout</span> = (<span class="params">{ children }: <span class="title class_">AuthLayoutProps</span></span>) => { </span><br><span class="line"> <span class="keyword">return</span> ( </span><br><span class="line"> <span class="language-xml"><span class="tag"><<span class="name">div</span> <span class="attr">className</span>=<span class="string">"flex flex-col min-h-screen items-center justify-center bg-base-200"</span>></span> </span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">UnverifiedBanner</span> /></span> </span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">div</span> <span class="attr">className</span>=<span class="string">"w-full max-w-md bg-base-100 p-8 rounded-lg shadow-xl"</span>></span> </span></span><br><span class="line"><span class="language-xml"> {children} </span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">div</span>></span> </span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">div</span>></span></span> </span><br><span class="line"> ); </span><br><span class="line">}; </span><br><span class="line"> </span><br><span class="line"><span class="keyword">export</span> <span class="keyword">default</span> <span class="title class_">AuthLayout</span>; </span><br></pre></td></tr></tbody></table></figure>
<p>这种改进带来了多方面的好处。用户可以及时了解自己的邮箱验证状态,避免因未验证而影响正常使用。同时,组件提供了直接的验证操作入口,使用户能够快速完成身份确认。此外,它保持了统一的视觉风格,与整体界面设计相协调,并且不会影响原有的布局结构,确保页面的整洁与一致性。</p>
<p>为了解决用户可能未收到验证邮件或验证邮件过期的问题,我们需要实现验证邮件重发功能:</p>
<figure class="highlight tsx"><figcaption><span>ResendVerification.tsx</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br><span class="line">83</span><br><span class="line">84</span><br><span class="line">85</span><br><span class="line">86</span><br><span class="line">87</span><br><span class="line">88</span><br><span class="line">89</span><br><span class="line">90</span><br><span class="line">91</span><br><span class="line">92</span><br><span class="line">93</span><br><span class="line">94</span><br><span class="line">95</span><br><span class="line">96</span><br><span class="line">97</span><br><span class="line">98</span><br><span class="line">99</span><br><span class="line">100</span><br><span class="line">101</span><br><span class="line">102</span><br><span class="line">103</span><br><span class="line">104</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> <span class="title class_">React</span>, {useCallback, useEffect} <span class="keyword">from</span> <span class="string">'react'</span>;</span><br><span class="line"><span class="keyword">import</span> { useNavigate } <span class="keyword">from</span> <span class="string">'react-router-dom'</span>;</span><br><span class="line"><span class="keyword">import</span> {useMutation} <span class="keyword">from</span> <span class="string">'@tanstack/react-query'</span>;</span><br><span class="line"><span class="keyword">import</span> { useUserStore } <span class="keyword">from</span> <span class="string">'../stores'</span>;</span><br><span class="line"><span class="keyword">import</span> api <span class="keyword">from</span> <span class="string">'../stores/common/api'</span>;</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> <span class="title function_">ResendVerification</span> = (<span class="params"></span>) => {</span><br><span class="line"> <span class="keyword">const</span> navigate = <span class="title function_">useNavigate</span>();</span><br><span class="line"></span><br><span class="line"> <span class="keyword">const</span> {</span><br><span class="line"> user,</span><br><span class="line"> verificationUserId,</span><br><span class="line"> clearVerificationState</span><br><span class="line"> } = <span class="title function_">useUserStore</span>(<span class="function"><span class="params">state</span> =></span> ({</span><br><span class="line"> <span class="attr">user</span>: state.<span class="property">user</span>,</span><br><span class="line"> <span class="attr">verificationUserId</span>: state.<span class="property">verificationUserId</span>,</span><br><span class="line"> <span class="attr">clearVerificationState</span>: state.<span class="property">clearVerificationState</span></span><br><span class="line"> }));</span><br><span class="line"></span><br><span class="line"> <span class="keyword">const</span> { <span class="attr">mutate</span>: resendEmail, isPending, error } = <span class="title function_">useMutation</span>({</span><br><span class="line"> <span class="attr">mutationFn</span>: <span class="title function_">async</span> (<span class="attr">userId</span>: <span class="built_in">string</span>) => {</span><br><span class="line"> <span class="keyword">const</span> response = <span class="keyword">await</span> api.<span class="title function_">post</span>(<span class="string">`/users/resend-verification/<span class="subst">${userId}</span>`</span>);</span><br><span class="line"> <span class="keyword">return</span> response.<span class="property">data</span>;</span><br><span class="line"> },</span><br><span class="line"> <span class="attr">onSuccess</span>: <span class="function">() =></span> {</span><br><span class="line"> <span class="title function_">navigate</span>(<span class="string">'/login'</span>, {</span><br><span class="line"> <span class="attr">state</span>: { <span class="attr">message</span>: <span class="string">'新的验证邮件已发送至你的注册邮箱'</span> },</span><br><span class="line"> <span class="attr">replace</span>: <span class="literal">true</span></span><br><span class="line"> });</span><br><span class="line"> }</span><br><span class="line"> })</span><br><span class="line"></span><br><span class="line"> <span class="keyword">const</span> handleResend = <span class="title function_">useCallback</span>(<span class="function">() =></span> {</span><br><span class="line"> <span class="keyword">if</span> (!verificationUserId) {</span><br><span class="line"> <span class="variable language_">console</span>.<span class="title function_">error</span>(<span class="string">'缺少用户 ID'</span>);</span><br><span class="line"> <span class="keyword">return</span>;</span><br><span class="line"> }</span><br><span class="line"> <span class="title function_">resendEmail</span>(verificationUserId);</span><br><span class="line"> }, [verificationUserId, resendEmail]);</span><br><span class="line"></span><br><span class="line"> <span class="title function_">useEffect</span>(<span class="function">() =></span> {</span><br><span class="line"> <span class="keyword">return</span> <span class="function">() =></span> {</span><br><span class="line"> <span class="title function_">clearVerificationState</span>();</span><br><span class="line"> };</span><br><span class="line"> }, [clearVerificationState]);</span><br><span class="line"></span><br><span class="line"> <span class="keyword">return</span> (</span><br><span class="line"> <span class="language-xml"><span class="tag"><<span class="name">div</span> <span class="attr">className</span>=<span class="string">"min-h-screen flex items-center justify-center bg-base-200"</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">div</span> <span class="attr">className</span>=<span class="string">"card w-96 bg-base-100 shadow-xl"</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">div</span> <span class="attr">className</span>=<span class="string">"card-body items-center text-center"</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">h2</span> <span class="attr">className</span>=<span class="string">"card-title mb-4"</span>></span></span></span><br><span class="line"><span class="language-xml"> 邮箱验证</span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">h2</span>></span></span></span><br><span class="line"><span class="language-xml"></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">div</span> <span class="attr">className</span>=<span class="string">"flex flex-col items-center gap-4"</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">p</span> <span class="attr">className</span>=<span class="string">"text-sm"</span>></span></span></span><br><span class="line"><span class="language-xml"> 你的邮箱 <span class="tag"><<span class="name">strong</span> <span class="attr">className</span>=<span class="string">"text-primary"</span>></span>{user?.email}<span class="tag"></<span class="name">strong</span>></span> 还未验证</span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">p</span>></span></span></span><br><span class="line"><span class="language-xml"></span></span><br><span class="line"><span class="language-xml"> {error && (</span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">div</span> <span class="attr">className</span>=<span class="string">"alert alert-error shadow-lg"</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">div</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">svg</span> <span class="attr">xmlns</span>=<span class="string">"http://www.w3.org/2000/svg"</span> <span class="attr">className</span>=<span class="string">"stroke-current flex-shrink-0 h-6 w-6"</span> <span class="attr">fill</span>=<span class="string">"none"</span> <span class="attr">viewBox</span>=<span class="string">"0 0 24 24"</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">path</span> <span class="attr">strokeLinecap</span>=<span class="string">"round"</span> <span class="attr">strokeLinejoin</span>=<span class="string">"round"</span> <span class="attr">strokeWidth</span>=<span class="string">"2"</span> <span class="attr">d</span>=<span class="string">"M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"</span> /></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">svg</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">span</span>></span>{error.message}<span class="tag"></<span class="name">span</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">div</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">div</span>></span></span></span><br><span class="line"><span class="language-xml"> )}</span></span><br><span class="line"><span class="language-xml"></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">button</span></span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">onClick</span>=<span class="string">{handleResend}</span></span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">disabled</span>=<span class="string">{isPending}</span></span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">className</span>=<span class="string">{</span>`<span class="attr">btn</span> <span class="attr">btn-primary</span> <span class="attr">w-full</span> ${<span class="attr">isPending</span> ? '<span class="attr">loading</span>' <span class="attr">:</span> ''}`}</span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> ></span></span></span><br><span class="line"><span class="language-xml"> {isPending ? '发送中...' : '重新发送验证邮件'}</span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">button</span>></span></span></span><br><span class="line"><span class="language-xml"></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">div</span> <span class="attr">className</span>=<span class="string">"text-sm mt-4"</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">p</span> <span class="attr">className</span>=<span class="string">"text-gray-500"</span>></span></span></span><br><span class="line"><span class="language-xml"> 没有收到邮件?请检查垃圾邮件文件夹</span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">p</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">p</span> <span class="attr">className</span>=<span class="string">"text-gray-500 mt-2"</span>></span></span></span><br><span class="line"><span class="language-xml"> 需要修改邮箱?前往{' '}</span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">a</span></span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">href</span>=<span class="string">"/settings"</span></span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">className</span>=<span class="string">"link link-primary"</span></span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">onClick</span>=<span class="string">{(e)</span> =></span> {</span></span><br><span class="line"><span class="language-xml"> e.preventDefault();</span></span><br><span class="line"><span class="language-xml"> navigate('/settings');</span></span><br><span class="line"><span class="language-xml"> }}</span></span><br><span class="line"><span class="language-xml"> ></span></span><br><span class="line"><span class="language-xml"> 账户设置</span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">a</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">p</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">div</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">div</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">div</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">div</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">div</span>></span></span></span><br><span class="line"> );</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">default</span> <span class="title class_">ResendVerification</span>;</span><br></pre></td></tr></tbody></table></figure>
<p>通过这个功能,我们为用户提供了一个完整的邮箱验证补救方案,帮助他们顺利完成账号验证过程。</p>
<h2 id="46-错误边界处理"><a class="markdownIt-Anchor" href="#46-错误边界处理"></a> 4.6. 错误边界处理</h2>
<p>在复杂的单页应用中,错误处理是一个非常重要的环节。为了防止应用因为某个组件的错误而完全崩溃,我们引入了错误边界(Error Boundary)机制。</p>
<p>安装 <code>react-error-boundary</code>:</p>
<figure class="highlight bash"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">yarn add react-error-boundary</span><br></pre></td></tr></tbody></table></figure>
<h4 id="461-应用入口改造"><a class="markdownIt-Anchor" href="#461-应用入口改造"></a> 4.6.1. 应用入口改造</h4>
<p>首先,我们在应用的最顶层添加错误边界保护。修改 <code>App.tsx</code>:</p>
<figure class="highlight tsx"><figcaption><span>App.tsx</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> <span class="title class_">React</span> <span class="keyword">from</span> <span class="string">'react'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">RouterProvider</span> } <span class="keyword">from</span> <span class="string">'react-router-dom'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">ErrorBoundary</span> } <span class="keyword">from</span> <span class="string">'react-error-boundary'</span>;</span><br><span class="line"><span class="keyword">import</span> router <span class="keyword">from</span> <span class="string">'./router'</span>;</span><br><span class="line"><span class="keyword">import</span> <span class="title class_">ErrorFallback</span> <span class="keyword">from</span> <span class="string">'./components/ErrorFallback'</span>;</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> <span class="title function_">App</span> = (<span class="params"></span>) => {</span><br><span class="line"> <span class="keyword">return</span> (</span><br><span class="line"> <span class="language-xml"><span class="tag"><<span class="name">ErrorBoundary</span> <span class="attr">FallbackComponent</span>=<span class="string">{ErrorFallback}</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">RouterProvider</span> <span class="attr">router</span>=<span class="string">{</span> <span class="attr">router</span> } /></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">ErrorBoundary</span>></span></span></span><br><span class="line"> );</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">default</span> <span class="title class_">App</span>;</span><br></pre></td></tr></tbody></table></figure>
<p>这样做可以捕获整个应用中的 React 组件错误,防止应用崩溃。</p>
<h4 id="462-错误回退组件"><a class="markdownIt-Anchor" href="#462-错误回退组件"></a> 4.6.2. 错误回退组件</h4>
<p>我们创建了一个专门的错误回退组件 <code>ErrorFallback.tsx</code>,用于显示错误信息并提供重试功能:</p>
<figure class="highlight tsx"><figcaption><span>ErrorFallback.tsx</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> <span class="title class_">React</span> <span class="keyword">from</span> <span class="string">'react'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">FallbackProps</span> } <span class="keyword">from</span> <span class="string">'react-error-boundary'</span>;</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> <span class="title function_">ErrorFallback</span> = (<span class="params">{ error, resetErrorBoundary }: <span class="title class_">FallbackProps</span></span>) => {</span><br><span class="line"> <span class="keyword">return</span> (</span><br><span class="line"> <span class="language-xml"><span class="tag"><<span class="name">div</span> <span class="attr">className</span>=<span class="string">"alert alert-error shadow-lg"</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">div</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">svg</span> <span class="attr">xmlns</span>=<span class="string">"http://www.w3.org/2000/svg"</span> <span class="attr">className</span>=<span class="string">"stroke-current flex-shrink-0 h-6 w-6"</span> <span class="attr">fill</span>=<span class="string">"none"</span> <span class="attr">viewBox</span>=<span class="string">"0 0 24 24"</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">path</span> <span class="attr">strokeLinecap</span>=<span class="string">"round"</span> <span class="attr">strokeLinejoin</span>=<span class="string">"round"</span> <span class="attr">strokeWidth</span>=<span class="string">"2"</span> <span class="attr">d</span>=<span class="string">"M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"</span> /></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">svg</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">div</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">h3</span> <span class="attr">className</span>=<span class="string">"font-bold"</span>></span>发生错误!<span class="tag"></<span class="name">h3</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">pre</span> <span class="attr">className</span>=<span class="string">"whitespace-pre-wrap"</span>></span>{error.message}<span class="tag"></<span class="name">pre</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">div</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">div</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">button</span></span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">className</span>=<span class="string">"btn btn-sm btn-primary"</span></span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">onClick</span>=<span class="string">{resetErrorBoundary}</span></span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> ></span></span></span><br><span class="line"><span class="language-xml"> 重试</span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">button</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">div</span>></span></span></span><br><span class="line"> );</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">default</span> <span class="title class_">ErrorFallback</span>;</span><br></pre></td></tr></tbody></table></figure>
<p>错误回退组件具备以下核心功能,旨在提升错误处理的可读性和用户体验:</p>
<ol>
<li>提供清晰的错误信息展示。组件能够显示友好的错误提示,同时呈现具体的错误详情,并保持错误信息的格式化显示,以便用户理解问题所在。</li>
<li>支持错误恢复功能。用户可以通过重试按钮尝试重新执行操作,同时,<code>resetErrorBoundary</code> 方法允许重置错误状态,使应用能够正常运行,让用户得以从错误中恢复。</li>
<li>在用户体验方面,组件采用统一的错误提示样式,确保与应用的整体设计风格保持一致。同时,它提供清晰的操作指引,帮助用户快速做出合适的应对操作,从而减少因错误导致的使用困扰。</li>
</ol>
<h2 id="47-路由访问控制"><a class="markdownIt-Anchor" href="#47-路由访问控制"></a> 4.7. 路由访问控制</h2>
<p>为了确保用户只能访问其权限内的页面,我们需要实现路由保护机制。这包括对私有路由的保护和对公共路由的控制。</p>
<h4 id="471-受保护路由组件"><a class="markdownIt-Anchor" href="#471-受保护路由组件"></a> 4.7.1. 受保护路由组件</h4>
<p>创建 <code>ProtectedRoute</code> 组件用于保护需要登录才能访问的页面:</p>
<figure class="highlight tsx"><figcaption><span>ProtectedRoute.tsx</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> <span class="title class_">React</span> <span class="keyword">from</span> <span class="string">'react'</span>;</span><br><span class="line"><span class="keyword">import</span> {useLocation, <span class="title class_">Navigate</span>} <span class="keyword">from</span> <span class="string">'react-router-dom'</span>;</span><br><span class="line"><span class="keyword">import</span> {useUserStore} <span class="keyword">from</span> <span class="string">'../stores'</span>;</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> <span class="title function_">ProtectedRoute</span> = (<span class="params">{ children }: { children: React.ReactNode }</span>) => {</span><br><span class="line"> <span class="keyword">const</span> user = <span class="title function_">useUserStore</span>(<span class="function"><span class="params">state</span> =></span> state.<span class="property">user</span>);</span><br><span class="line"> <span class="keyword">const</span> isAutoLoading = <span class="title function_">useUserStore</span>(<span class="function"><span class="params">state</span> =></span> state.<span class="property">isAutoLoading</span>);</span><br><span class="line"> <span class="keyword">const</span> location = <span class="title function_">useLocation</span>();</span><br><span class="line"></span><br><span class="line"> <span class="comment">// <span class="doctag">TODO:</span> 返回一个加载动画</span></span><br><span class="line"> <span class="keyword">if</span> (isAutoLoading) <span class="keyword">return</span> <span class="language-xml"><span class="tag"><<span class="name">div</span>></span>加载中……<span class="tag"></<span class="name">div</span>></span></span>;</span><br><span class="line"></span><br><span class="line"> <span class="keyword">if</span> (!user) <span class="keyword">return</span> <span class="language-xml"><span class="tag"><<span class="name">Navigate</span> <span class="attr">to</span>=<span class="string">"/login"</span> <span class="attr">state</span>=<span class="string">{{from:</span> <span class="attr">location</span>}} <span class="attr">replace</span> /></span></span>;</span><br><span class="line"></span><br><span class="line"> <span class="keyword">return</span> <span class="language-xml"><span class="tag"><></span>{children}<span class="tag"></></span></span>;</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">default</span> <span class="title class_">ProtectedRoute</span>;</span><br></pre></td></tr></tbody></table></figure>
<h4 id="472-公共路由组件"><a class="markdownIt-Anchor" href="#472-公共路由组件"></a> 4.7.2. 公共路由组件</h4>
<p>创建 <code>PublicRoute</code> 组件用于处理登录、注册等公共页面:</p>
<figure class="highlight tsx"><figcaption><span>PublicRoute.tsx</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> <span class="title class_">React</span> <span class="keyword">from</span> <span class="string">'react'</span>;</span><br><span class="line"><span class="keyword">import</span> {useUserStore} <span class="keyword">from</span> <span class="string">'../stores'</span>;</span><br><span class="line"><span class="keyword">import</span> {<span class="title class_">Navigate</span>, useLocation} <span class="keyword">from</span> <span class="string">'react-router-dom'</span>;</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> <span class="title function_">PublicRoute</span> = (<span class="params">{children} : {children: React.ReactNode}</span>) => {</span><br><span class="line"> <span class="keyword">const</span> user = <span class="title function_">useUserStore</span>(<span class="function"><span class="params">state</span> =></span> state.<span class="property">user</span>);</span><br><span class="line"> <span class="keyword">const</span> isAutoLoading = <span class="title function_">useUserStore</span>(<span class="function"><span class="params">state</span> =></span> state.<span class="property">isAutoLoading</span>);</span><br><span class="line"> <span class="keyword">const</span> location = <span class="title function_">useLocation</span>();</span><br><span class="line"></span><br><span class="line"> <span class="keyword">if</span> (isAutoLoading) <span class="keyword">return</span> <span class="language-xml"><span class="tag"><<span class="name">div</span>></span>加载中……<span class="tag"></<span class="name">div</span>></span></span>;</span><br><span class="line"></span><br><span class="line"> <span class="keyword">if</span> (user) {</span><br><span class="line"> <span class="keyword">const</span> <span class="keyword">from</span> = location.<span class="property">state</span>?.<span class="property">from</span>?.<span class="property">pathname</span> || <span class="string">'/'</span>;</span><br><span class="line"> <span class="keyword">return</span> <span class="language-xml"><span class="tag"><<span class="name">Navigate</span> <span class="attr">to</span>=<span class="string">{from}</span> <span class="attr">replace</span> /></span></span>;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">return</span> <span class="language-xml"><span class="tag"><></span>{children}<span class="tag"></></span></span>;</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">default</span> <span class="title class_">PublicRoute</span>;</span><br></pre></td></tr></tbody></table></figure>
<h4 id="473-路由配置优化"><a class="markdownIt-Anchor" href="#473-路由配置优化"></a> 4.7.3. 路由配置优化</h4>
<p>使用这些保护组件来包装路由:</p>
<figure class="highlight tsx"><figcaption><span>router.tsx</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> router = <span class="title function_">createBrowserRouter</span>([ </span><br><span class="line"> { </span><br><span class="line"> <span class="attr">path</span>: <span class="string">'/'</span>, </span><br><span class="line"> <span class="attr">element</span>: <span class="language-xml"><span class="tag"><<span class="name">ProtectedRoute</span>></span><span class="tag"><<span class="name">MainPage</span> /></span><span class="tag"></<span class="name">ProtectedRoute</span>></span></span> </span><br><span class="line"> }, </span><br><span class="line"> { </span><br><span class="line"> <span class="attr">path</span>: <span class="string">'/register'</span>, </span><br><span class="line"> <span class="attr">element</span>: <span class="language-xml"><span class="tag"><<span class="name">PublicRoute</span>></span><span class="tag"><<span class="name">Register</span> /></span><span class="tag"></<span class="name">PublicRoute</span>></span></span> </span><br><span class="line"> }, </span><br><span class="line"> { </span><br><span class="line"> <span class="attr">path</span>: <span class="string">'/login'</span>, </span><br><span class="line"> <span class="attr">element</span>: <span class="language-xml"><span class="tag"><<span class="name">PublicRoute</span>></span><span class="tag"><<span class="name">Login</span> /></span><span class="tag"></<span class="name">PublicRoute</span>></span></span> </span><br><span class="line"> }, </span><br><span class="line"> { </span><br><span class="line"> <span class="attr">path</span>: <span class="string">'/verify-email'</span>, </span><br><span class="line"> <span class="attr">element</span>: <span class="language-xml"><span class="tag"><<span class="name">VerifyEmail</span> /></span></span> </span><br><span class="line"> }, </span><br><span class="line"> { </span><br><span class="line"> <span class="attr">path</span>: <span class="string">'/resend-verification'</span>, </span><br><span class="line"> <span class="attr">element</span>: <span class="language-xml"><span class="tag"><<span class="name">ResendVerification</span> /></span></span> </span><br><span class="line"> } </span><br><span class="line">]);</span><br></pre></td></tr></tbody></table></figure>
<blockquote>
<p>可以写一个简单的 <code>MainPage</code> 组件来测试登出功能:</p>
<figure class="highlight tsx"><figcaption><span>MainPage.tsx</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> <span class="title class_">React</span> <span class="keyword">from</span> <span class="string">'react'</span>;</span><br><span class="line"><span class="keyword">import</span> { useUserStore } <span class="keyword">from</span> <span class="string">'../stores'</span>;</span><br><span class="line"><span class="keyword">import</span> <span class="title class_">MainLayout</span> <span class="keyword">from</span> <span class="string">'../layouts/MainLayout'</span>;</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> <span class="title function_">MainPage</span> = (<span class="params"></span>) => {</span><br><span class="line"> <span class="keyword">const</span> logout = <span class="title function_">useUserStore</span>(<span class="function"><span class="params">state</span> =></span> state.<span class="property">logout</span>);</span><br><span class="line"></span><br><span class="line"> <span class="keyword">return</span> (</span><br><span class="line"> <span class="language-xml"><span class="tag"><<span class="name">MainLayout</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">button</span> <span class="attr">onClick</span>=<span class="string">{logout}</span>></span>登出<span class="tag"></<span class="name">button</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">MainLayout</span>></span></span></span><br><span class="line"> )</span><br><span class="line">};</span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">default</span> <span class="title class_">MainPage</span>;</span><br></pre></td></tr></tbody></table></figure>
</blockquote>
</body></html></div></article></div></main><footer><div class="paginator"><a class="prev" href="6976.html">上一篇</a><a class="next" href="567.html">下一篇</a></div><!-- Webmention 显示区域--><div class="webmention-section webmention-empty" data-page-url="posts/270a.html" data-full-url="https://cytrogen.icu/posts/270a.html" data-mode="static">
<h3 class="webmention-title">Webmentions (<span class="webmention-count">0</span>)</h3>
<div class="webmention-list"></div>
<span>暂无 Webmentions</span>
</div><div class="copyright"><p class="footer-links"><a href="../friends/index.html">友链</a><span class="footer-separator"> ·</span><a href="../links/index.html">邻邦</a><span class="footer-separator"> ·</span><a href="../contact/index.html">联络</a><span class="footer-separator"> ·</span><a href="../colophon/index.html">营造记</a><span class="footer-separator"> ·</span><a href="../atom.xml">RSS订阅</a></p><p>© 2025 - 2026 <a href="https://cytrogen.icu">Cytrogen</a>, powered by <a href="https://hexo.io/" target="_blank">Hexo</a> and <a href="https://github.com/cytrogen/hexo-theme-ares" target="_blank">hexo-theme-ares</a>.</p><p><a href="https://blogscn.fun" target="_blank" rel="noopener">BLOGS·CN</a></p></div></footer></div></div><a class="back-to-top" href="#top" aria-label="返回顶部"><svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M3.293 9.707a1 1 0 010-1.414L9.586 2a2 2 0 012.828 0l6.293 6.293a1 1 0 01-1.414 1.414L11 3.414V17a1 1 0 11-2 0V3.414L2.707 9.707a1 1 0 01-1.414 0z"></path></svg></a><script>document.addEventListener('DOMContentLoaded', function() {
const codeBlocks = document.querySelectorAll('figure.highlight');
codeBlocks.forEach(block => {
let caption = block.querySelector('figcaption');
if (!caption) {
caption = document.createElement('figcaption');
block.insertBefore(caption, block.firstChild);
}
const info = document.createElement('div');
info.className = 'info';
const filename = caption.querySelector('span');
if (filename) {
filename.className = 'filename';
info.appendChild(filename);
}
const lang = block.className.split(' ')[1];
if (lang) {
const langSpan = document.createElement('span');
langSpan.className = 'lang-name';
langSpan.textContent = lang;
info.appendChild(langSpan);
}
const sourceLink = caption.querySelector('a');
if (sourceLink) {
sourceLink.className = 'source-link';
info.appendChild(sourceLink);
}
const actions = document.createElement('div');
actions.className = 'actions';
const codeHeight = block.scrollHeight;
const threshold = 300;
if (codeHeight > threshold) {
block.classList.add('folded');
const toggleBtn = document.createElement('button');
toggleBtn.textContent = '展开';
toggleBtn.addEventListener('click', () => {
block.classList.toggle('folded');
toggleBtn.textContent = block.classList.contains('folded') ? '展开' : '折叠';
});
actions.appendChild(toggleBtn);
}
const copyBtn = document.createElement('button');
copyBtn.textContent = '复制';
copyBtn.addEventListener('click', async () => {
const codeLines = block.querySelectorAll('.code .line');
const code = Array.from(codeLines)
.map(line => line.textContent)
.join('\n')
.replace(/\n\n/g, '\n');
try {
await navigator.clipboard.writeText(code);
copyBtn.textContent = '已复制';
copyBtn.classList.add('copied');
setTimeout(() => {
copyBtn.textContent = '复制';
copyBtn.classList.remove('copied');
}, 3000);
} catch (err) {
console.error('复制失败:', err);
copyBtn.textContent = '复制失败';
setTimeout(() => {
copyBtn.textContent = '复制';
}, 3000);
}
});
actions.appendChild(copyBtn);
caption.innerHTML = '';
caption.appendChild(info);
caption.appendChild(actions);
const markedLines = block.getAttribute('data-marked-lines');
if (markedLines) {
const lines = markedLines.split(',');
lines.forEach(range => {
if (range.includes('-')) {
const [start, end] = range.split('-').map(Number);
for (let i = start; i <= end; i++) {
const line = block.querySelector(`.line-${i}`);
if (line) line.classList.add('marked');
}
} else {
const line = block.querySelector(`.line-${range}`);
if (line) line.classList.add('marked');
}
});
}
});
});</script><script async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js" id="MathJax-script"></script><script>(function() {
document.addEventListener('DOMContentLoaded', function() {
const themeToggle = document.querySelector('.theme-toggle');
if (!themeToggle) return;
const getCurrentTheme = () => {
return document.documentElement.getAttribute('data-theme') || 'light';
};
const updateUI = (theme) => {
const isDark = theme === 'dark';
themeToggle.setAttribute('aria-pressed', isDark.toString());
};
const setTheme = (theme) => {
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.style.colorScheme = theme;
const pageWrapper = document.getElementById('page-wrapper');
if (pageWrapper) {
pageWrapper.setAttribute('data-theme', theme);
}
// Find and remove the temporary anti-flicker style tag if it exists.
// This ensures the main stylesheet takes full control after the initial load.
const antiFlickerStyle = document.getElementById('anti-flicker-style');
if (antiFlickerStyle) {
antiFlickerStyle.remove();
}
localStorage.setItem('theme', theme);
updateUI(theme);
};
const toggleTheme = () => {
const current = getCurrentTheme();
const newTheme = current === 'light' ? 'dark' : 'light';
setTheme(newTheme);
};
updateUI(getCurrentTheme());
themeToggle.addEventListener('click', toggleTheme);
if (window.matchMedia) {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', function(e) {
if (!localStorage.getItem('theme')) {
const theme = e.matches ? 'dark' : 'light';
setTheme(theme);
}
});
}
});
})();
</script><script src="../js/details-toggle.js" defer></script><script>(function() {
document.addEventListener('DOMContentLoaded', function() {
const backToTopBtn = document.querySelector('.back-to-top');
if (!backToTopBtn) return;
const toggleButtonVisibility = () => {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const shouldShow = scrollTop > 200;
if (shouldShow) {
backToTopBtn.classList.add('is-visible');
} else {
backToTopBtn.classList.remove('is-visible');
}
};
let ticking = false;
const handleScroll = () => {
if (!ticking) {
requestAnimationFrame(() => {
toggleButtonVisibility();
ticking = false;
});
ticking = true;
}
};
const scrollToTop = (event) => {
event.preventDefault();
window.scrollTo({
top: 0,
behavior: 'smooth'
});
};
window.addEventListener('scroll', handleScroll);
backToTopBtn.addEventListener('click', scrollToTop);
toggleButtonVisibility();
});
})();</script></body></html>