<!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 购物平台练习【4】用户注册功能 · Cytrogen 的个人博客</title><meta name="description" content="本文是 React + NestJS 全栈购物平台实践的第四篇,专注于实现完整的用户注册与邮箱验证功能。前端部分,教程详细讲解了如何使用 Formik 和 Yup 构建带实时校验的注册表单,并通过 Zustand 进行全局状态管理。后端部分,则深入实现了用户注册 API,涵盖了密码的 bcrypt 加密、数据库操作,并集成了邮件服务以发送验证链接。此外,文章还分享了开发过程中遇到的 BUG 及其解决方案,为构建一个安全、可靠的用户认证流程提供了详尽的实战指导。"><link rel="icon" href="../favicon.png"><link rel="canonical" href="https://cytrogen.icu/posts/8853.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/8853.html">永久链接</a><div class="p-summary visually-hidden"><p>全栈实践又到了我们喜闻乐见的用户注册功能……</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 购物平台练习【4】用户注册功能</h1><div class="post-info"><time class="post-date dt-published" datetime="2024-11-28T05:00:00.000Z">11/28/2024</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>
<span id="more"></span>
<h1 id="1-修-bug"><a class="markdownIt-Anchor" href="#1-修-bug"></a> 1. 修 BUG</h1>
<p>我们先修一下先前写的 BUG……</p>
<h2 id="11-导出-bug"><a class="markdownIt-Anchor" href="#11-导出-bug"></a> 1.1. 导出 BUG</h2>
<p>在我们先前创建的 <code>src/stores/index.ts</code> 中,我们写的是:</p>
<figure class="highlight ts"><figcaption><span>index.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> useUserStore <span class="keyword">from</span> <span class="string">'./user'</span>;</span><br></pre></td></tr></tbody></table></figure>
<p>这样写的话,实际运行项目后会报错。</p>
<p>正确的写法是:</p>
<figure class="highlight ts"><figcaption><span>index.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">export</span> { useUserStore } <span class="keyword">from</span> <span class="string">'./user'</span>;</span><br></pre></td></tr></tbody></table></figure>
<ol>
<li>
<p><code>index.ts</code> 是用于导出所有的 Store,因此是 <code>export</code> 而不是 <code>import</code></p>
</li>
<li>
<p>要理解为什么必须使用 <code>{ useUserStore }</code> 而不是 <code>useUserStore</code>,我们需要了解 JavaScript 和 TypeScript 中的默认导出(default export)和具名导出(named export)之间的区别</p>
</li>
</ol>
<h4 id="111-具名导出"><a class="markdownIt-Anchor" href="#111-具名导出"></a> 1.1.1. 具名导出</h4>
<p>在 <code>stores/user/index.ts</code> 中,<code>useUserStore</code> 是通过具名导出方式来导出的:</p>
<figure class="highlight ts"><figcaption><span>index.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">export</span> <span class="keyword">const</span> useUserStore = <span class="title function_">createSelectors</span>(useUserStoreBase);</span><br></pre></td></tr></tbody></table></figure>
<p>这表示我们将 <code>useUserStore</code> 作为一个具名导出,并且它可以通过具名导出来引用。具名导出的语法正是:</p>
<figure class="highlight ts"><figcaption><span>index.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">export</span> { useUserStore } <span class="keyword">from</span> <span class="string">'./user'</span>;</span><br></pre></td></tr></tbody></table></figure>
<h4 id="112-默认导出"><a class="markdownIt-Anchor" href="#112-默认导出"></a> 1.1.2. 默认导出</h4>
<p>如果我们使用的是默认导出,那么导出时应该写成:</p>
<figure class="highlight ts"><figcaption><span>index.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">export</span> <span class="keyword">default</span> useUserStore;</span><br></pre></td></tr></tbody></table></figure>
<p>然后我们才可以在其他文件使用默认导出语法:</p>
<figure class="highlight ts"><figcaption><span>index.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">export</span> useUserStore <span class="keyword">from</span> <span class="string">'./user'</span>;</span><br></pre></td></tr></tbody></table></figure>
<h2 id="12-实体关系-bug"><a class="markdownIt-Anchor" href="#12-实体关系-bug"></a> 1.2. 实体关系 BUG</h2>
<p>后续在后端开发完注册 API、运行应用时,会遇到实体关系配置错误:</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">TypeORMError: Entity metadata for Users#orders was not found. Check if you specified a correct entity object and if it's connected in the connection options.</span><br></pre></td></tr></tbody></table></figure>
<p>这是因为 NestJS 使用 TypeORM 时需要通过 <code>TypeOrmModule</code> 注册所有的实体。如果一个实体(例如 <code>Users</code>)中引用了其他实体(例如 <code>Orders</code>),TypeORM 会尝试加载并解析这些关系。如果 <code>Orders</code> 没有在 <code>TypeOrmModule</code> 配置中注册,TypeORM 将会因为找不到该实体的元数据而抛出错误。</p>
<p>唉,这时候有人该问了,<a href="/posts/8e94">第二篇</a> 里不是已经写了个 <code>database.module.ts</code> 来统一配置吗?里面还配置了个 <code>autoLoadEntities</code> 呢。</p>
<p>在数据库模块的配置中,我们确实加上了 <code>autoLoadEntities: true</code>,希望能自动加载所有实体,避免每次手动注册实体。然而,<code>autoLoadEntities</code> 的工作机制并非全局加载所有实体,它仅自动加载通过 <code>TypeOrmModule.forFeature()</code> 注册的实体。因此,如果某个实体未被 <code>forFeature</code> 导入到任何模块中,TypeORM 无法找到它。</p>
<p>在 <code>TypeOrmModule</code> 中,有三种方式来配置实体加载,每种方式有不同的适用场景:</p>
<ol>
<li>
<p>单独定义:</p>
<figure class="highlight ts"><figcaption><span>database.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="title class_">TypeOrmModule</span>.<span class="title function_">forRoot</span>({</span><br><span class="line"> <span class="comment">//...</span></span><br><span class="line"> <span class="attr">entities</span>: [</span><br><span class="line"> <span class="title class_">Users</span>,</span><br><span class="line"> <span class="title class_">Products</span>,</span><br><span class="line"> <span class="title class_">Payments</span>,</span><br><span class="line"> <span class="title class_">Orders</span>,</span><br><span class="line"> <span class="title class_">OrderItems</span>,</span><br><span class="line"> <span class="title class_">InventoryLogs</span>,</span><br><span class="line"> <span class="title class_">Categories</span>,</span><br><span class="line"> <span class="title class_">Carts</span>,</span><br><span class="line"> <span class="title class_">CartItems</span>,</span><br><span class="line"> <span class="title class_">Addresses</span>,</span><br><span class="line"> ],</span><br><span class="line">}),</span><br></pre></td></tr></tbody></table></figure>
<p>这种方式适合开发阶段,明确知道所有实体的数量和位置时,每当创建新实体时手动添加即可。但在项目复杂、实体较多或依赖多模块的情况下,逐一引入会显得繁琐,容易出错,且不便于代码维护。</p>
</li>
<li>
<p>自动加载:</p>
<figure class="highlight ts"><figcaption><span>database.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></pre></td><td class="code"><pre><span class="line"><span class="title class_">TypeOrmModule</span>.<span class="title function_">forRoot</span>({</span><br><span class="line"> <span class="comment">//...</span></span><br><span class="line"> <span class="attr">autoLoadEntities</span>: <span class="literal">true</span>,</span><br><span class="line">}),</span><br></pre></td></tr></tbody></table></figure>
<p><code>autoLoadEntities</code> 会自动加载通过 <code>TypeOrmModule.forFeature()</code> 引入的实体。这意味着在模块中显式使用 <code>TypeOrmModule.forFeature([Entity])</code> 的实体将被自动添加到连接配置的 <code>entities</code> 数组中。</p>
<blockquote>
<p>注意:</p>
<p>这要求每个模块中要包含实体的 <code>forFeature</code> 配置。否则未注册的实体将无法自动加载,容易引发实体关系配置错误。</p>
<p>在 <code>Users</code> 实体中,<code>Orders</code> 实体被作为关联实体引用(<code>@OneToMany(() => Orders, order => order.user)</code>),因此 TypeORM 会尝试在 <code>entities</code> 配置数组中找到并加载 <code>Orders</code> 的元数据。由于 <code>Orders</code> 没有在任何模块的 <code>forFeature</code> 中注册,TypeORM 会在解析 <code>Users</code> 实体的关系时找不到 <code>Orders</code>,导致报错。</p>
</blockquote>
</li>
<li>
<p>自定义引入路径:</p>
<figure class="highlight ts"><figcaption><span>database.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></pre></td><td class="code"><pre><span class="line"><span class="title class_">TypeOrmModule</span>.<span class="title function_">forRoot</span>({</span><br><span class="line"> <span class="comment">//...</span></span><br><span class="line"> <span class="attr">entities</span>: [<span class="string">'dist/**/*.entity{.ts,.js}'</span>],</span><br><span class="line">}),</span><br></pre></td></tr></tbody></table></figure>
<p>这是官方推荐的方式,使用通配符路径直接加载所有编译后的实体文件(如 <code>dist/entities/*.entity.js</code>),避免了逐一手动添加的麻烦,并保证所有实体自动注册,减少遗漏问题。</p>
</li>
</ol>
<p>在我们的 <code>database.module.ts</code> 中添加第三种方式的配置,即可解决报错。</p>
<h1 id="2-实现注册表单组件"><a class="markdownIt-Anchor" href="#2-实现注册表单组件"></a> 2. 实现注册表单组件</h1>
<h2 id="21-基础结构"><a class="markdownIt-Anchor" href="#21-基础结构"></a> 2.1. 基础结构</h2>
<p>我们的注册页面由两个主要部分组成:</p>
<ul>
<li><code>Register</code> 组件:处理表单逻辑、验证以及提交请求</li>
<li><code>AuthLayout</code> 组件:负责提供页面的布局和样式</li>
</ul>
<p><code>Register</code> 组件嵌套在 <code>AuthLayout</code> 中,这样可以确保页面结构保持一致。</p>
<p>首先,我们来看看 <code>AuthLayout.tsx</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></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><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 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">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>这里使用了 Tailwind CSS 进行样式布局,<code>AuthLayout</code> 用于包装页面的子元素,确保其在屏幕上居中显示,并有一定的阴影和内边距。</p>
<p>接着在 <code>src/pages</code> 目录下创建 <code>Register.tsx</code>。</p>
<h2 id="22-表单开发"><a class="markdownIt-Anchor" href="#22-表单开发"></a> 2.2. 表单开发</h2>
<p>在我们的 <code>Register</code> 组件中,我们将使用 <code>Formik</code> 来处理表单的状态管理和提交,并使用 <code>Yup</code> 来进行表单验证。<code>Formik</code> 和 <code>Yup</code> 的结合提供了简洁且强大的表单验证和管理能力。</p>
<p>用以下命令安装 <code>Formik</code> 和 <code>Yup</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 formik yup</span><br></pre></td></tr></tbody></table></figure>
<h4 id="221-formik-用法"><a class="markdownIt-Anchor" href="#221-formik-用法"></a> 2.2.1. <code>Formik</code> 用法</h4>
<p><code>Formik</code> 通过 <code>useFormik</code> Hook 管理表单的状态和行为。在 <code>Register</code> 页面中,我们初始化了一个表单,提供了初始值和 <code>onSubmit</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></pre></td><td class="code"><pre><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><br><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> 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">email</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">confirmPassword</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="function">(<span class="params">values</span>) =></span> {</span><br><span class="line"> <span class="title function_">setIsSubmitting</span>(<span class="literal">true</span>);</span><br><span class="line"> <span class="built_in">setTimeout</span>(<span class="function">() =></span> {</span><br><span class="line"> <span class="variable language_">console</span>.<span class="title function_">log</span>(values); <span class="comment">// 模拟提交</span></span><br><span class="line"> <span class="title function_">setIsSubmitting</span>(<span class="literal">false</span>);</span><br><span class="line"> }, <span class="number">2000</span>);</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><br></pre></td></tr></tbody></table></figure>
<ul>
<li><code>initialValues</code>:初始化表单的默认值</li>
<li><code>validationSchema</code>:使用 <code>Yup</code> 创建的验证规则(稍后会详细介绍)</li>
<li><code>onSubmit</code>:处理表单提交的函数
<ul>
<li><code>setIsSubmitting(true)</code>:在表单提交时,我们将 <code>isSubmitting</code> 设置为 <code>true</code>,这会触发按钮禁用以及按钮文本更新为「注册中……」</li>
<li><code>setTimeout</code>:为了模拟实际的提交过程,我们使用 <code>setTimeout</code> 延迟了 2 秒钟。实际应用中这里应该替换为 API 请求,不过我们的 API 还没完成呢</li>
<li><code>setIsSubmitting(false)</code>:当提交操作完成时,我们将 <code>isSubmitting</code> 设置为 <code>false</code>,恢复按钮的正常状态</li>
</ul>
</li>
</ul>
<h4 id="222-yup-验证"><a class="markdownIt-Anchor" href="#222-yup-验证"></a> 2.2.2. <code>Yup</code> 验证</h4>
<p><code>Yup</code> 是一个 JavaScript 的对象模式验证库,我们通过它来定义表单的验证规则。下面是每个字段的验证规则:</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></pre></td><td class="code"><pre><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><br><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> validationSchema = <span class="title class_">Yup</span>.<span class="title function_">object</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">email</span>: <span class="title class_">Yup</span>.<span class="title function_">string</span>().<span class="title function_">email</span>(<span class="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_">min</span>(<span class="number">6</span>, <span class="string">"密码必须至少包含6个字符!"</span>).<span class="title function_">required</span>(<span class="string">"请输入密码!"</span>),</span><br><span class="line"> <span class="attr">confirmPassword</span>: <span class="title class_">Yup</span>.<span class="title function_">string</span>()</span><br><span class="line"> .<span class="title function_">oneOf</span>([<span class="title class_">Yup</span>.<span class="title function_">ref</span>(<span class="string">"password"</span>)], <span class="string">"密码不匹配!"</span>)</span><br><span class="line"> .<span class="title function_">required</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><br></pre></td></tr></tbody></table></figure>
<ul>
<li><code>username</code>:必须填写,并且是字符串</li>
<li><code>email</code>:必须添加,并且符合邮箱格式</li>
<li><code>password</code>:必须至少有 6 个字符</li>
<li><code>confirmPassword</code>:必须与密码匹配</li>
</ul>
<p><code>Yup</code> 和 <code>Formik</code> 的结合提供了简洁的验证机制,自动管理每个字段的错误信息,并在表单提交时触发验证。</p>
<h4 id="223-表单渲染"><a class="markdownIt-Anchor" href="#223-表单渲染"></a> 2.2.3. 表单渲染</h4>
<p>表单输入框的渲染非常直观。我们通过 <code>formik.handleChange</code> 来处理用户输入,并通过 <code>formik.errors</code> 和 <code>formik.touched</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></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="comment">// ...</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">AuthLayout</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 mb-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 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 class="attr">formik.touched.username</span> && <span class="attr">formik.errors.username</span> ? "<span class="attr">border-error</span>" <span class="attr">:</span> "<span class="attr">border-neutral-600</span>"} <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></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></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><span class="line">};</span><br></pre></td></tr></tbody></table></figure>
<ul>
<li><code>value</code>:绑定表单值</li>
<li><code>onChange</code> 和 <code>onBlur</code>:处理表单输入和失去焦点事件</li>
<li><code>formik.errors</code>:在字段发生错误时显示错误信息</li>
</ul>
<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><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></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="comment">// ...</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">AuthLayout</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 mb-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 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></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">"email"</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">"email"</span></span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">id</span>=<span class="string">"email"</span></span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">name</span>=<span class="string">"email"</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 class="attr">formik.touched.email</span> && <span class="attr">formik.errors.email</span> ? "<span class="attr">border-error</span>" <span class="attr">:</span> "<span class="attr">border-neutral-600</span>"} <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.email}</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></span></span><br><span class="line"><span class="language-xml"> {formik.touched.email && formik.errors.email && (</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.email}<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 class="attr">formik.touched.password</span> && <span class="attr">formik.errors.password</span> ? "<span class="attr">border-error</span>" <span class="attr">:</span> "<span class="attr">border-neutral-600</span>"} <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></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">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">"confirmPassword"</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">"confirmPassword"</span></span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">name</span>=<span class="string">"confirmPassword"</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 class="attr">formik.touched.confirmPassword</span> && <span class="attr">formik.errors.confirmPassword</span> ? "<span class="attr">border-error</span>" <span class="attr">:</span> "<span class="attr">border-neutral-600</span>"} <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.confirmPassword}</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></span></span><br><span class="line"><span class="language-xml"> {formik.touched.confirmPassword && formik.errors.confirmPassword && (</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.confirmPassword}<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 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><span class="line">};</span><br></pre></td></tr></tbody></table></figure>
<h4 id="224-按钮的状态管理"><a class="markdownIt-Anchor" href="#224-按钮的状态管理"></a> 2.2.4. 按钮的状态管理</h4>
<p>按钮的状态管理对于处理表单提交时的交互反馈是非常重要的。</p>
<p><code>useState</code> 是 React 中用于管理组件状态的 Hook。它允许我们在函数组件内部创建一个可变的状态,并返回一个更新该状态的函数。在注册页面中,我们使用 <code>useState</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> <span class="title class_">React</span>, { useState } <span class="keyword">from</span> <span class="string">'react'</span>;</span><br><span class="line"></span><br><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> [isSubmitting, setIsSubmitting] = useState<<span class="built_in">boolean</span>>(<span class="literal">false</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>
<ul>
<li><code>isSubmitting</code>:表示表单是否正在提交,初始值是 <code>false</code>,即默认情况下表单没有提交</li>
<li><code>setIsSubmitting</code>:用于更新 <code>isSubmitting</code> 状态的函数</li>
</ul>
<p>每当表单提交时,我们会将 <code>isSubmitting</code> 设置为 <code>true</code>,表示正在进行提交操作。当提交完成后,再将其设置回 <code>false</code>。</p>
<h4 id="225-按钮的状态变化"><a class="markdownIt-Anchor" href="#225-按钮的状态变化"></a> 2.2.5. 按钮的状态变化</h4>
<p>在注册页面中,表单的提交按钮(<code><button></code>)会根据 <code>isSubmitting</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></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="comment">// ...</span></span><br><span class="line"></span><br><span class="line"> <span class="keyword">return</span> (</span><br><span class="line"> <<span class="title class_">AuthLayout</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 mb-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 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></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 class="attr">type</span>=<span class="string">"submit"</span> <span class="attr">className</span>=<span class="string">"w-full bg-primary text-primary-content py-2 rounded-lg mt-4 hover:brightness-90"</span> <span class="attr">disabled</span>=<span class="string">{isSubmitting}</span>></span></span></span><br><span class="line"><span class="language-xml"> {isSubmitting ? "注册中……" : "注册"}</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><br><span class="line">};</span><br><span class="line"></button></span><br></pre></td></tr></tbody></table></figure>
<ul>
<li><code>disabled={isSubmitting}</code>:按钮在提交时被禁用,防止用户多次点击。每次表单提交时,<code>isSubmitting</code> 会被设为 <code>true</code>,这将使按钮处于禁用状态,直到提交结束</li>
<li>按钮文本切换:根据 <code>isSubmitting</code> 的值,按钮的文本会动态改变
<ul>
<li>如果表单正在提交(<code>isSubmitting === true</code>),按钮文本会显示为「注册中……」</li>
<li>如果表单没有提交(<code>isSubmitting === false</code>),按钮显示为「注册」</li>
</ul>
</li>
</ul>
<h2 id="23-路由配置"><a class="markdownIt-Anchor" href="#23-路由配置"></a> 2.3. 路由配置</h2>
<p>为了使我们的页面能够被访问和管理,在我们的 <code>router.tsx</code> 中添加路由 <code>/register</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><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> <span class="title class_">Register</span> <span class="keyword">from</span> <span class="string">'./pages/Register'</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 class="comment">// ...</span></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">Register</span> /></span></span></span><br><span class="line"> }</span><br><span class="line">]);</span><br></pre></td></tr></tbody></table></figure>
<p>运行 React 项目,访问 <code>localhost:3000/register</code>:</p>
<p><img src="/posts/8853/1.png" alt="Register页面预览"></p>
<blockquote>
<p>图片是另外加的。</p>
</blockquote>
<h1 id="3-实现后端注册-api"><a class="markdownIt-Anchor" href="#3-实现后端注册-api"></a> 3. 实现后端注册 API</h1>
<p>在现代的 Web 应用开发中,用户注册功能是几乎每个系统都需要的基础部分。用户注册不仅需要保存用户的基本信息,还要确保密码等敏感数据的安全性。</p>
<p>设想这样一个场景:我们正在开发一个用户系统,要求用户可以通过提供必要的个人信息进行注册,并创建一个账号。由于用户密码是非常敏感的信息,我们必须在保存密码之前进行加密,以确保其安全性。此外,我们还需要在需要时提供其他用户管理的接口,如更新、删除等操作。</p>
<h2 id="31-创建用户模块"><a class="markdownIt-Anchor" href="#31-创建用户模块"></a> 3.1. 创建用户模块</h2>
<p>用户模块负责管理用户相关的逻辑,创建 <code>users.module.ts</code> 文件:</p>
<figure class="highlight ts"><figcaption><span>users.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></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_">TypeOrmModule</span> } <span class="keyword">from</span> <span class="string">'@nestjs/typeorm'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">UsersController</span> } <span class="keyword">from</span> <span class="string">'./users.controller'</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.service'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">Users</span> } <span class="keyword">from</span> <span class="string">'../entities/users.entity'</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 class="title class_">TypeOrmModule</span>.<span class="title function_">forFeature</span>([<span class="title class_">Users</span>])],</span><br><span class="line"> <span class="attr">controllers</span>: [<span class="title class_">UsersController</span>],</span><br><span class="line"> <span class="attr">providers</span>: [<span class="title class_">UsersService</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_">UsersModule</span> {}</span><br></pre></td></tr></tbody></table></figure>
<p>在此模块中,我们使用了 <code>TypeOrmModule.forFeature([Users])</code> 来将用户实体与 TypeORM 绑定,以便在 <code>UsersService</code> 中使用数据库的增删查改功能。</p>
<h2 id="32-创建用户控制器"><a class="markdownIt-Anchor" href="#32-创建用户控制器"></a> 3.2. 创建用户控制器</h2>
<p>控制器负责定义 API 路由和对应的处理方法,创建 <code>users.controller.ts</code> 文件:</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><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></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_">Get</span>, <span class="title class_">Body</span>, <span class="title class_">Param</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_">UsersService</span> } <span class="keyword">from</span> <span class="string">'./users.service'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">Users</span> } <span class="keyword">from</span> <span class="string">'../entities/users.entity'</span>;</span><br><span class="line"></span><br><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="title function_">constructor</span>(<span class="params"><span class="keyword">private</span> <span class="keyword">readonly</span> <span class="attr">usersService</span>: <span class="title class_">UsersService</span></span>) {}</span><br><span class="line"></span><br><span class="line"> <span class="meta">@Post</span>(<span class="string">'create'</span>)</span><br><span class="line"> <span class="title function_">create</span>(<span class="meta">@Body</span>() <span class="attr">user</span>: <span class="title class_">Partial</span><<span class="title class_">Users</span>>): <span class="title class_">Promise</span><<span class="title class_">Users</span>> {</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_">create</span>(user);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="meta">@Get</span>(<span class="string">':id'</span>)</span><br><span class="line"> <span class="title function_">findById</span>(<span class="meta">@Param</span>(<span class="string">'id'</span>) <span class="attr">id</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">return</span> <span class="variable language_">this</span>.<span class="property">usersService</span>.<span class="title function_">findById</span>(id);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="meta">@Post</span>(<span class="string">'update/:id'</span>)</span><br><span class="line"> <span class="title function_">update</span>(<span class="meta">@Param</span>(<span class="string">'id'</span>) <span class="attr">id</span>: <span class="built_in">string</span>, <span class="meta">@Body</span>() <span class="attr">updateUser</span>: <span class="title class_">Partial</span><<span class="title class_">Users</span>>): <span class="title class_">Promise</span><<span class="title class_">Users</span>> {</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_">update</span>(id, updateUser);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="meta">@Post</span>(<span class="string">'delete/:id'</span>)</span><br><span class="line"> <span class="title function_">remove</span>(<span class="meta">@Param</span>(<span class="string">'id'</span>) <span class="attr">id</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">return</span> <span class="variable language_">this</span>.<span class="property">usersService</span>.<span class="title function_">remove</span>(id);</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
<p>我们定义了以下几个方法:</p>
<ul>
<li><code>create</code>:用于处理用户注册的 POST 请求,调用 <code>UsersService.create</code> 方法以保存用户信息</li>
<li><code>findById</code>、<code>update</code> 和 <code>remove</code> 方法:分别用于获取、更新和删除用户信息</li>
</ul>
<h2 id="33-编写用户服务"><a class="markdownIt-Anchor" href="#33-编写用户服务"></a> 3.3. 编写用户服务</h2>
<p><code>UsersService</code> 负责处理具体的业务逻辑,包括数据的加密和与数据库的交互。在实现注册功能时,我们需要对用户密码进行加密,并将加密后的密码与其他信息一起保存到数据库。</p>
<p>创建 <code>users.service.ts</code>:</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><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> { <span class="title class_">Injectable</span>, <span class="title class_">NotFoundException</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_">InjectRepository</span> } <span class="keyword">from</span> <span class="string">'@nestjs/typeorm'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">Repository</span> } <span class="keyword">from</span> <span class="string">'typeorm'</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_">Users</span> } <span class="keyword">from</span> <span class="string">'../entities/users.entity'</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_">UsersService</span> {</span><br><span class="line"> <span class="title function_">constructor</span>(<span class="params"><span class="meta">@InjectRepository</span>(Users) <span class="keyword">private</span> <span class="attr">usersRepository</span>: <span class="title class_">Repository</span><<span class="title class_">Users</span>></span>) {}</span><br><span class="line"></span><br><span class="line"> <span class="keyword">async</span> <span class="title function_">create</span>(<span class="attr">user</span>: <span class="title class_">Partial</span><<span class="title class_">Users</span>>): <span class="title class_">Promise</span><<span class="title class_">Users</span>> {</span><br><span class="line"> <span class="keyword">const</span> hashedPassword = <span class="keyword">await</span> bcrypt.<span class="title function_">hash</span>(user.<span class="property">password</span>, <span class="number">10</span>);</span><br><span class="line"> <span class="keyword">const</span> newUser = <span class="variable language_">this</span>.<span class="property">usersRepository</span>.<span class="title function_">create</span>({</span><br><span class="line"> ...user,</span><br><span class="line"> <span class="attr">password</span>: hashedPassword</span><br><span class="line"> });</span><br><span class="line"> <span class="keyword">return</span> <span class="variable language_">this</span>.<span class="property">usersRepository</span>.<span class="title function_">save</span>(newUser);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">async</span> <span class="title function_">update</span>(<span class="attr">id</span>: <span class="built_in">string</span>, <span class="attr">updateUser</span>: <span class="title class_">Partial</span><<span class="title class_">Users</span>>): <span class="title class_">Promise</span><<span class="title class_">Users</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="title function_">findById</span>(id);</span><br><span class="line"> <span class="keyword">if</span> (updateUser.<span class="property">password</span>) {</span><br><span class="line"> updateUser.<span class="property">password</span> = <span class="keyword">await</span> bcrypt.<span class="title function_">hash</span>(updateUser.<span class="property">password</span>, <span class="number">10</span>);</span><br><span class="line"> }</span><br><span class="line"> <span class="title class_">Object</span>.<span class="title function_">assign</span>(user, updateUser);</span><br><span class="line"> <span class="keyword">return</span> <span class="variable language_">this</span>.<span class="property">usersRepository</span>.<span class="title function_">save</span>(user);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">async</span> <span class="title function_">remove</span>(<span class="attr">id</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">const</span> result = <span class="keyword">await</span> <span class="variable language_">this</span>.<span class="property">usersRepository</span>.<span class="title function_">delete</span>(id);</span><br><span class="line"> <span class="keyword">if</span> (result.<span class="property">affected</span> === <span class="number">0</span>) {</span><br><span class="line"> <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">NotFoundException</span>(<span class="string">`User with ID <span class="subst">${id}</span> is not found`</span>);</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">async</span> <span class="title function_">findById</span>(<span class="attr">id</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> user = <span class="keyword">await</span> <span class="variable language_">this</span>.<span class="property">usersRepository</span>.<span class="title function_">findOneBy</span>({ id });</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_">NotFoundException</span>(<span class="string">`User with ID <span class="subst">${id}</span> is not found`</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><code>UsersService</code> 实现了以下方法:</p>
<ul>
<li><code>create</code>:用于创建用户。在保存用户数据前,通过 <code>bcrypt.hash</code> 方法对密码进行加密,然后将加密后的用户数据保存到数据库</li>
<li><code>update</code>:用于更新用户信息。如果更新的数据中包含 <code>password</code>,则需要再次加密后再保存</li>
<li><code>remove</code>:用于删除用户信息。如果删除操作没有影响任何行,则会抛出 <code>NotFoundException</code> 错误</li>
<li><code>findById</code>:用于根据用户 ID 查询用户信息。找不到用户时同样会抛出 <code>NotFoundException</code></li>
</ul>
<h2 id="34-通过-swagger-生成文档标签"><a class="markdownIt-Anchor" href="#34-通过-swagger-生成文档标签"></a> 3.4. 通过 Swagger 生成文档标签</h2>
<p>我们将为每个控制器方法添加 Swagger 标签:</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><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></pre></td><td class="code"><pre><span class="line"><span class="comment">// ...</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_">ApiBody</span>, <span class="title class_">ApiResponse</span>, <span class="title class_">ApiParam</span> } <span class="keyword">from</span> <span class="string">'@nestjs/swagger'</span>;</span><br><span class="line"></span><br><span class="line"><span class="meta">@ApiTags</span>(<span class="string">'Users'</span>)</span><br><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="comment">// ...</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">@ApiBody</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">@ApiResponse</span>({ <span class="attr">status</span>: <span class="number">201</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">@Post</span>(<span class="string">'create'</span>)</span><br><span class="line"> <span class="comment">// ...</span></span><br><span class="line"></span><br><span class="line"> <span class="meta">@ApiOperation</span>({ <span class="attr">summary</span>: <span class="string">'根据ID获取用户信息'</span> })</span><br><span class="line"> <span class="meta">@ApiParam</span>({ <span class="attr">name</span>: <span class="string">'id'</span>, <span class="attr">description</span>: <span class="string">'用户ID'</span> })</span><br><span class="line"> <span class="meta">@ApiResponse</span>({ <span class="attr">status</span>: <span class="number">200</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">@Get</span>(<span class="string">':id'</span>)</span><br><span class="line"> <span class="comment">// ...</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">@ApiParam</span>({ <span class="attr">name</span>: <span class="string">'id'</span>, <span class="attr">description</span>: <span class="string">'用户ID'</span> })</span><br><span class="line"> <span class="meta">@ApiBody</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">@ApiResponse</span>({ <span class="attr">status</span>: <span class="number">200</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">@Post</span>(<span class="string">'update/:id'</span>)</span><br><span class="line"> <span class="comment">// ...</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">@ApiParam</span>({ <span class="attr">name</span>: <span class="string">'id'</span>, <span class="attr">description</span>: <span class="string">'用户ID'</span> })</span><br><span class="line"> <span class="meta">@ApiResponse</span>({ <span class="attr">status</span>: <span class="number">200</span>, <span class="attr">description</span>: <span class="string">'用户删除成功'</span> })</span><br><span class="line"> <span class="meta">@Post</span>(<span class="string">'delete/:id'</span>)</span><br><span class="line"> <span class="comment">// ...</span></span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
<ul>
<li><code>@ApiTags('Users')</code>:将此标签添加到控制器顶部,使得所有方法都归入 <code>Users</code> 类别,便于在 Swagger 文档中管理</li>
<li><code>@ApiOperation</code>:为每个方法添加操作说明,使其清晰描述了 API 的功能</li>
<li><code>@ApiBody</code>:为 <code>POST</code> 请求体参数添加描述,指定了参数内容的描述和数据类型</li>
<li><code>@ApiParam</code>:为路径参数添加说明,方便开发者了解需要传入的参数</li>
<li><code>@ApiResponse</code>:指定响应的状态码和描述信息,以及返回的数据类型,便于用户了解响应格式</li>
</ul>
<p>访问 <code>localhost:APP_PORT/api-docs</code> 来查看 Swagger 文档。</p>
<h2 id="35-使用-dto-来进行优化"><a class="markdownIt-Anchor" href="#35-使用-dto-来进行优化"></a> 3.5. 使用 DTO 来进行优化</h2>
<p>在现代 Web 应用中,数据传输对象(DTO)是与外部通信的标准化方式之一。在 NestJS 项目中,DTO(Data Transfer Object)不仅帮助你确保传递的数据格式一致,还提供了结构化和验证机制,使得 API 接口更加清晰、安全和可维护。</p>
<p>DTO 通常用于:</p>
<ul>
<li>定义 API 接口所需的请求体和响应体的结构</li>
<li>对数据进行验证和转换</li>
<li>确保前后端在数据传输时遵循相同的约定</li>
</ul>
<p>在我们已经编写了注册用户的逻辑后,为什么还需要创建 DTO 并用它来顶替原本的写法?</p>
<p>答案是:<strong>增强数据验证和规范化输入输出</strong>。</p>
<ol>
<li>
<p>增强验证机制:DTO 通过类验证器(如 <code>class-validator</code>)来确保传入的数据符合预期。例如,我们可以设置 <code>MinLength</code> 来确保密码长度不小于 6 个字符、使用 <code>IsEmail</code> 来验证邮箱格式。这不仅使代码更加规范,而且还大大提高了 API 的可靠性和安全性</p>
</li>
<li>
<p>清晰的 API 结构:DTO 使 API 请求和响应体更加清晰。通过 DTO,前后端可以明确约定需要的数据字段和格式,这样可以减少由于数据格式不匹配导致的错误</p>
</li>
<li>
<p>易于扩展和维护:DTO 提供了一个灵活的扩展点。如果未来业务需求发生变化,我们只需修改 DTO,而不必修改整个业务逻辑。这样,系统的可维护性和扩展性更强</p>
</li>
</ol>
<p>我们已经有了一个用于用户注册的业务逻辑,现在我们要将 DTO 集成到 <code>UsersController</code> 和 <code>UsersService</code> 中。</p>
<h4 id="351-创建-dto"><a class="markdownIt-Anchor" href="#351-创建-dto"></a> 3.5.1. 创建 DTO</h4>
<p>在 <code>src/users</code> 目录下创建 <code>dto</code> 目录,并在其中再创建一个 <code>create-user.dto.ts</code> 文件:</p>
<figure class="highlight ts"><figcaption><span>create-user.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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> { <span class="title class_">IsString</span>, <span class="title class_">IsEmail</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_">CreateUserDto</span> {</span><br><span class="line"> <span class="meta">@IsString</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">@IsEmail</span>()</span><br><span class="line"> <span class="attr">email</span>: <span class="built_in">string</span>;</span><br><span class="line"></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>
<p><code>CreateUserDto</code> 定义了用户注册时需要传入的字段,并且为这些字段添加了验证规则:</p>
<ul>
<li><code>username</code> 字段要求是字符串类型</li>
<li><code>email</code> 字段要求是有效的邮箱格式</li>
<li><code>password</code> 字段要求密码长度至少为 6 个字符</li>
</ul>
<h4 id="352-在控制器中使用-dto"><a class="markdownIt-Anchor" href="#352-在控制器中使用-dto"></a> 3.5.2. 在控制器中使用 DTO</h4>
<p>接下来,在 <code>UsersController</code> 中,我们将 <code>CreateUserDto</code> 引入,并将其用于 <code>create</code> 方法的请求体验证。我们需要使用 <code>@Body()</code> 装饰器将请求体映射到 DTO 类。</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><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// ...</span></span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">CreateUserDto</span> } <span class="keyword">from</span> <span class="string">'./dto/create-user.dto'</span>;</span><br><span class="line"></span><br><span class="line"><span class="meta">@ApiTags</span>(<span class="string">'Users'</span>)</span><br><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="comment">// ...</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">@ApiBody</span>({ <span class="attr">description</span>: <span class="string">'用户信息'</span>, <span class="attr">type</span>: <span class="title class_">CreateUserDto</span> }) <span class="comment">// 使用DTO类型</span></span><br><span class="line"> <span class="meta">@ApiResponse</span>({ <span class="attr">status</span>: <span class="number">201</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">@Post</span>(<span class="string">'create'</span>)</span><br><span class="line"> <span class="title function_">create</span>(<span class="meta">@Body</span>() <span class="attr">user</span>: <span class="title class_">CreateUserDto</span>): <span class="title class_">Promise</span><<span class="title class_">Users</span>> { <span class="comment">// 接收CreateUserDto类型</span></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_">create</span>(user);</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><br></pre></td></tr></tbody></table></figure>
<p>在上述代码中:</p>
<ul>
<li>使用 <code>@ApiBody</code> 注解指定了请求体的描述,<code>type</code> 字段使用 <code>CreateUserDto</code></li>
<li><code>@Body()</code> 装饰器会将请求体映射到 <code>CreateUserDto</code> 类型,从而进行数据验证</li>
</ul>
<h4 id="353-在服务中使用-dto"><a class="markdownIt-Anchor" href="#353-在服务中使用-dto"></a> 3.5.3. 在服务中使用 DTO</h4>
<p>在 <code>UsersService</code> 中,<code>create</code> 方法接收了 <code>CreateUserDto</code> 类型的参数,并在保存到数据库之前进行密码的哈希处理:</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></pre></td><td class="code"><pre><span class="line"><span class="comment">// ...</span></span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">CreateUserDto</span> } <span class="keyword">from</span> <span class="string">'./dto/create-user.dto'</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_">UsersService</span> {</span><br><span class="line"> <span class="title function_">constructor</span>(<span class="params"><span class="meta">@InjectRepository</span>(Users) <span class="keyword">private</span> <span class="attr">usersRepository</span>: <span class="title class_">Repository</span><<span class="title class_">Users</span>></span>) {}</span><br><span class="line"></span><br><span class="line"> <span class="keyword">async</span> <span class="title function_">create</span>(<span class="attr">user</span>: <span class="title class_">CreateUserDto</span>): <span class="title class_">Promise</span><<span class="title class_">Users</span>> {</span><br><span class="line"> <span class="keyword">const</span> hashedPassword = <span class="keyword">await</span> bcrypt.<span class="title function_">hash</span>(user.<span class="property">password</span>, <span class="number">10</span>); <span class="comment">// 使用DTO中的password</span></span><br><span class="line"> <span class="keyword">const</span> newUser = <span class="variable language_">this</span>.<span class="property">usersRepository</span>.<span class="title function_">create</span>({</span><br><span class="line"> ...user,</span><br><span class="line"> <span class="attr">password</span>: hashedPassword</span><br><span class="line"> });</span><br><span class="line"> <span class="keyword">return</span> <span class="variable language_">this</span>.<span class="property">usersRepository</span>.<span class="title function_">save</span>(newUser);</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
<p>在这里,我们在服务层接收的参数是 <code>CreateUserDto</code> 类型,它包含了所有必要的字段和验证规则。通过这种方式,我们避免了在控制器中进行复杂的验证操作,将其交给 DTO 来处理,使得业务逻辑更加简洁和清晰。</p>
<h4 id="354-定义-swagger-schema"><a class="markdownIt-Anchor" href="#354-定义-swagger-schema"></a> 3.5.4. 定义 Swagger Schema</h4>
<p>在 NestJS 中,你可以通过在 DTO 中使用 Swagger 的注解来定义和生成 API 的 Schema。</p>
<figure class="highlight ts"><figcaption><span>create-user.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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> { <span class="title class_">IsString</span>, <span class="title class_">IsEmail</span>, <span class="title class_">MinLength</span> } <span class="keyword">from</span> <span class="string">'class-validator'</span>;</span><br><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_">CreateUserDto</span> {</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 class="string">'john_doe'</span></span><br><span class="line"> })</span><br><span class="line"> <span class="meta">@IsString</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><br><span class="line"> <span class="attr">description</span>: <span class="string">'用户的邮箱地址,必须为有效的邮箱格式'</span>,</span><br><span class="line"> <span class="attr">example</span>: <span class="string">'johndoe@example.com'</span></span><br><span class="line"> })</span><br><span class="line"> <span class="meta">@IsEmail</span>()</span><br><span class="line"> <span class="attr">email</span>: <span class="built_in">string</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">'密码,必须至少包含6个字符'</span>,</span><br><span class="line"> <span class="attr">example</span>: <span class="string">'password123456'</span>,</span><br><span class="line"> <span class="attr">minLength</span>: <span class="number">6</span></span><br><span class="line"> })</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>
<p><img src="/posts/8853/2.png" alt="访问localhost:APP_PORT/api-docs,即可看到CreateUserDto Schema"></p>
<h2 id="36-与前端的集成"><a class="markdownIt-Anchor" href="#36-与前端的集成"></a> 3.6. 与前端的集成</h2>
<p>先前写前端代码的时候,我们的 <code>Register.tsx</code> 并没有连接到后端 API,而是使用 <code>setTimeout</code> 模拟了一下提交。</p>
<p>因此我们要对现有的前端代码进行修改和优化。</p>
<h4 id="361-添加状态管理逻辑"><a class="markdownIt-Anchor" href="#361-添加状态管理逻辑"></a> 3.6.1. 添加状态管理逻辑</h4>
<p>在 <code>Register.tsx</code> 中,我们使用了本地状态管理 <code>isSubmitting</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> [isSubmitting, setIsSubmitting] = useState<<span class="built_in">boolean</span>>(<span class="literal">false</span>);</span><br></pre></td></tr></tbody></table></figure>
<p>为了让状态的管理更统一,我们需要将其改为使用全局状态管理,也为之后添加更多功能奠定了基础。</p>
<p>首先是修改 <code>stores/index.ts</code>:</p>
<figure class="highlight ts"><figcaption><span>index.ts</span></figcaption><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">export</span> { useUserStore } <span class="keyword">from</span> <span class="string">'./user'</span>;</span><br></pre></td></tr></tbody></table></figure>
<p>接着在 <code>Register.tsx</code> 中使用 <code>useUserStore</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></pre></td><td class="code"><pre><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="comment">// ...</span></span><br><span class="line"></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></pre></td></tr></tbody></table></figure>
<ul>
<li><code>register</code>:一个用于注册用户的全局方法</li>
<li><code>isLoading</code>:表单提交的状态</li>
<li><code>error</code>:记录注册过程中发生的错误信息</li>
</ul>
<h4 id="362-新增注册方法"><a class="markdownIt-Anchor" href="#362-新增注册方法"></a> 3.6.2. 新增注册方法</h4>
<p>在修改 <code>Register.tsx</code> 时,我们同样需要在 <code>stores</code> 目录下的状态管理逻辑中进行调整,以支持注册功能的完整实现。</p>
<p>首先在 <code>stores/user/types.ts</code> 中新增注册凭据类型:</p>
<figure class="highlight ts"><figcaption><span>types.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></pre></td><td class="code"><pre><span class="line"><span class="keyword">export</span> <span class="keyword">interface</span> <span class="title class_">RegisterCredentials</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></pre></td></tr></tbody></table></figure>
<p>并更新用户状态类型:</p>
<figure class="highlight ts"><figcaption><span>types.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="keyword">export</span> <span class="keyword">interface</span> <span class="title class_">UserState</span> {</span><br><span class="line"> <span class="comment">// ...</span></span><br><span class="line"> <span class="attr">register</span>: <span class="function">(<span class="params"><span class="attr">credentials</span>: <span class="title class_">RegisterCredentials</span></span>) =></span> <span class="title class_">Promise</span><<span class="title class_">User</span>>;</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
<p>接着在 <code>stores/user/actions.ts</code> 中新添注册功能:</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></pre></td><td class="code"><pre><span class="line"><span class="attr">register</span>: <span class="title function_">async</span> (<span class="attr">credentials</span>: <span class="title class_">RegisterCredentials</span>) => { </span><br><span class="line"> <span class="title function_">set</span>({ <span class="attr">isLoading</span>: <span class="literal">true</span>, <span class="attr">error</span>: <span class="literal">null</span> }); </span><br><span class="line"> <span class="keyword">try</span> { </span><br><span class="line"> <span class="keyword">const</span> <span class="attr">response</span>: <span class="title class_">AxiosResponse</span><<span class="title class_">User</span>> = <span class="keyword">await</span> api.<span class="property">post</span><<span class="title class_">User</span>>(<span class="string">'/users/create'</span>, credentials); </span><br><span class="line"> <span class="keyword">const</span> user = response.<span class="property">data</span>; </span><br><span class="line"></span><br><span class="line"> <span class="title function_">set</span>({ </span><br><span class="line"> <span class="attr">isLoading</span>: <span class="literal">false</span>, </span><br><span class="line"> <span class="attr">error</span>: <span class="literal">null</span>, </span><br><span class="line"> <span class="attr">lastUpdated</span>: <span class="title class_">Date</span>.<span class="title function_">now</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 class="keyword">catch</span> (error) { </span><br><span class="line"> <span class="keyword">const</span> errorMessage = 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 class="title function_">set</span>({ </span><br><span class="line"> <span class="attr">isLoading</span>: <span class="literal">false</span>, </span><br><span class="line"> <span class="attr">error</span>: errorMessage </span><br><span class="line"> }); </span><br><span class="line"></span><br><span class="line"> <span class="keyword">throw</span> error; </span><br><span class="line"> } </span><br><span class="line">}, </span><br></pre></td></tr></tbody></table></figure>
<p>主要逻辑为:</p>
<ul>
<li>初始化状态:设置 <code>isLoading</code> 为 <code>true</code>,清空可能的旧错误</li>
<li>调用注册 API:发送用户的注册信息到 <code>/users/create</code>,并解析后端返回的用户数据</li>
<li>成功处理:更新状态,但不设置 <code>user</code> 值,因为注册完成后还需登录</li>
<li>错误处理:捕获错误并设置错误信息</li>
<li>状态恢复:无论成功或失败,都将 <code>isLoading</code> 恢复为 <code>false</code></li>
</ul>
<h4 id="363-实现与后端的连接"><a class="markdownIt-Anchor" href="#363-实现与后端的连接"></a> 3.6.3. 实现与后端的连接</h4>
<p>先前我们用了 <code>setTimeout</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></pre></td><td class="code"><pre><span class="line"><span class="built_in">setTimeout</span>(<span class="function">() =></span> {</span><br><span class="line"> <span class="variable language_">console</span>.<span class="title function_">log</span>(values);</span><br><span class="line"> <span class="title function_">setIsSubmitting</span>(<span class="literal">false</span>);</span><br><span class="line">}, <span class="number">2000</span>);</span><br></pre></td></tr></tbody></table></figure>
<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></pre></td><td class="code"><pre><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></pre></td></tr></tbody></table></figure>
<p>成功后,跳转到 <code>/login</code> 页面,并通过 <code>state</code> 传递一条注册成功的信息。就算失败了,也会捕获错误信息,便于显示给用户。</p>
<p>由于我们在 <code>store</code> 中已经处理了注册错误,这里就不需要额外处理,直接 <code>console.error</code> 即可。</p>
<blockquote>
<p><code>navigate</code> 来自于 <code>react-router-dom</code> 库:</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></pre></td><td class="code"><pre><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><br><span class="line"><span class="comment">// ...</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> navigate = <span class="title function_">useNavigate</span>();</span><br></pre></td></tr></tbody></table></figure>
</blockquote>
<h4 id="364-用户错误信息提示"><a class="markdownIt-Anchor" href="#364-用户错误信息提示"></a> 3.6.4. 用户错误信息提示</h4>
<p>在 <code>Register.tsx</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></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></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>
<p>用户提交表单时,如果后端返回错误信息,将会在表单顶部显示一条清晰的错误提示。这提升了用户体验,让用户了解失败的原因并尝试修正。</p>
<h4 id="365-禁用表单控件"><a class="markdownIt-Anchor" href="#365-禁用表单控件"></a> 3.6.5. 禁用表单控件</h4>
<p>将 <code>Register.tsx</code> 中所有的 <code><input></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></pre></td><td class="code"><pre><span class="line">disabled={isLoading}</span><br></pre></td></tr></tbody></table></figure>
<p>这代表着提交过程中表单控件会被禁用,避免用户重复提交。</p>
<p><code>isLoading</code> 由状态管理工具提供,确保整个应用对状态变化的感知一致。</p>
<p>同样的,对 <code><button></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></pre></td><td class="code"><pre><span class="line"><button</span><br><span class="line"> <span class="keyword">type</span>=<span class="string">"submit"</span></span><br><span class="line"> className=<span class="string">"w-full bg-primary text-primary-content py-2 rounded-lg mt-4 hover:brightness-90 disabled:opacity-50"</span></span><br><span class="line"> disabled={isLoading}</span><br><span class="line">></span><br><span class="line"> {isLoading ? <span class="string">"注册中…"</span> : <span class="string">"注册"</span>}</span><br><span class="line"></button></span><br></pre></td></tr></tbody></table></figure>
<p>运行前端和后端,进行注册测试。</p>
<p>别忘了在前端目录下创建 <code>.env</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">REACT_APP_API_URL=http://localhost:后端的端口</span><br></pre></td></tr></tbody></table></figure>
<p>注册成功后你应该会被导向 <code>/login</code> 页面,上面会弹出:</p>
<figure class="highlight plaintext"><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">Unexpected Application Error!</span><br><span class="line">404 Not Found</span><br><span class="line">💿 Hey developer 👋</span><br><span class="line"></span><br><span class="line">You can provide a way better UX than this when your app throws errors by providing your own ErrorBoundary or errorElement prop on your route.</span><br></pre></td></tr></tbody></table></figure>
<h1 id="4-实现邮箱验证"><a class="markdownIt-Anchor" href="#4-实现邮箱验证"></a> 4. 实现邮箱验证</h1>
<p>邮箱验证是用户注册流程中的重要安全环节,旨在确认用户提供的邮箱地址是真实且可用、防止机器人和垃圾注册、提高账号安全性,同时为后续通信建立可靠的联系渠道。</p>
<p>典型的邮箱验证流程包括:</p>
<ol>
<li>用户注册提供邮箱</li>
<li>系统生成唯一验证令牌</li>
<li>发送包含验证链接的邮件</li>
<li>用户点击链接完成验证</li>
<li>系统校验令牌的有效性</li>
</ol>
<h2 id="41-前端实现"><a class="markdownIt-Anchor" href="#41-前端实现"></a> 4.1. 前端实现</h2>
<p>我们首先需要在路由中添加邮箱验证页面的路由。在 <code>router.tsx</code> 中进行修改:</p>
<figure class="highlight ts"><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> <span class="title class_">VerifyEmail</span> <span class="keyword">from</span> <span class="string">'./pages/VerifyEmail'</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 class="comment">// ...</span></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></pre></td></tr></tbody></table></figure>
<h4 id="411-zustand-状态管理更新"><a class="markdownIt-Anchor" href="#411-zustand-状态管理更新"></a> 4.1.1. Zustand 状态管理更新</h4>
<p>接下来,我们需要更新 Zustand 的用户状态管理,增加邮箱验证相关的状态的方法。</p>
<p>更新 <code>types.ts</code>,添加邮箱验证相关的枚举和接口:</p>
<figure class="highlight ts"><figcaption><span>types.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></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></pre></td></tr></tbody></table></figure>
<p>在实现邮箱验证功能时,我定义了三种可能的验证错误状态:</p>
<ol>
<li><code>TOKEN_INVALID</code>(无效令牌)</li>
</ol>
<ul>
<li>当用户提供的验证链接被篡改、不完整或不存在于系统</li>
<li>可能是用户误点击了错误的链接</li>
<li>可能是验证链接已被恶意修改</li>
<li>系统将拒绝这类验证请求,并显示错误提示</li>
</ul>
<ol start="2">
<li><code>TOKEN_EXPIRED</code>(令牌过期)</li>
</ol>
<ul>
<li>验证链接已超过有效期限(24 小时)</li>
<li>防止长期未使用的过期链接被重复使用</li>
<li>用户需要重新请求发送验证邮件</li>
<li>提示用户链接已过期,需要重新获取</li>
</ul>
<ol start="3">
<li><code>ALREADY_VERIFIED</code>(已验证)</li>
</ol>
<ul>
<li>用户尝试使用已经验证过的邮箱链接再次验证</li>
<li>可能是用户重复点击验证链接</li>
<li>系统将提示用户邮箱已成功验证</li>
<li>通常会直接引导用户登录</li>
</ul>
<figure class="highlight ts"><figcaption><span>types.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">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>
<p>验证响应接口则设计了一个标准的响应接口:</p>
<ul>
<li>验证是否成功的标志</li>
<li>返回消息</li>
<li>可选的错误类型</li>
<li>可选的用户 ID</li>
</ul>
<figure class="highlight ts"><figcaption><span>types.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></pre></td><td class="code"><pre><span class="line"><span class="keyword">export</span> <span class="keyword">interface</span> <span class="title class_">UserState</span> {</span><br><span class="line"> <span class="comment">// ...</span></span><br><span class="line"> <span class="attr">emailVerified</span>: <span class="built_in">boolean</span>;</span><br><span class="line"> <span class="attr">verificationError</span>?: <span class="title class_">EmailVerificationError</span>;</span><br><span class="line"> <span class="attr">verificationUserId</span>?: <span class="built_in">string</span>;</span><br><span class="line"> <span class="attr">verificationInProgress</span>: <span class="built_in">boolean</span>;</span><br><span class="line"> <span class="attr">verificationToken</span>?: <span class="built_in">string</span>;</span><br><span class="line"></span><br><span class="line"> <span class="attr">verifyEmail</span>: <span class="function">(<span class="params"><span class="attr">token</span>: <span class="built_in">string</span></span>) =></span> <span class="title class_">Promise</span><<span class="title class_">VerificationResponse</span>>;</span><br><span class="line"> <span class="attr">resendVerificationEmail</span>: <span class="function">(<span class="params"><span class="attr">userId</span>: <span class="built_in">string</span></span>) =></span> <span class="title class_">Promise</span><<span class="title class_">VerificationResponse</span>>;</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
<h4 id="412-邮箱验证组件基础结构"><a class="markdownIt-Anchor" href="#412-邮箱验证组件基础结构"></a> 4.1.2. 邮箱验证组件基础结构</h4>
<p>在 <code>pages</code> 目录下创建 <code>VerifyEmail.tsx</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></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> { useLocation, 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_">EmailVerificationError</span> } <span class="keyword">from</span> <span class="string">'../stores/user/types'</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="comment">// 获取路由和导航相关钩子</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><br><span class="line"> <span class="comment">// 从 Zustand store 中获取状态和方法</span></span><br><span class="line"> <span class="keyword">const</span> verifyEmail = <span class="title function_">useUserStore</span>(<span class="function"><span class="params">state</span> =></span> state.<span class="property">verifyEmail</span>); </span><br><span class="line"> <span class="keyword">const</span> resendVerificationEmail = <span class="title function_">useUserStore</span>(<span class="function"><span class="params">state</span> =></span> state.<span class="property">resendVerificationEmail</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 class="keyword">const</span> emailVerified = <span class="title function_">useUserStore</span>(<span class="function"><span class="params">state</span> =></span> state.<span class="property">emailVerified</span>); </span><br><span class="line"> <span class="keyword">const</span> verificationError = <span class="title function_">useUserStore</span>(<span class="function"><span class="params">state</span> =></span> state.<span class="property">verificationError</span>); </span><br><span class="line"> <span class="keyword">const</span> verificationUserId = <span class="title function_">useUserStore</span>(<span class="function"><span class="params">state</span> =></span> state.<span class="property">verificationUserId</span>); </span><br><span class="line"> <span class="keyword">const</span> verificationInProgress = <span class="title function_">useUserStore</span>(<span class="function"><span class="params">state</span> =></span> state.<span class="property">verificationInProgress</span>); </span><br><span class="line"></span><br><span class="line"> <span class="comment">// 渲染逻辑将在这里实现</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">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 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></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>
<p>状态管理部分详解:</p>
<ul>
<li>从 Zustand store 中提取多个状态和方法</li>
<li><code>verifyEmail</code>:邮箱验证方法</li>
<li><code>resendVerificationEmail</code>:重新发送验证邮件方法</li>
<li><code>isLoading</code>:加载状态</li>
<li><code>error</code>:错误信息</li>
<li><code>emailVerified</code>:邮箱是否已验证</li>
<li><code>verificationError</code>:验证错误类型</li>
<li><code>verificationUserId</code>:用于重发验证邮件的用户 ID</li>
<li><code>verificationInProgress</code>:验证是否正在进行中</li>
</ul>
<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></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> handleVerification = <span class="title function_">useCallback</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">try</span> { </span><br><span class="line"> <span class="keyword">const</span> result = <span class="keyword">await</span> <span class="title function_">verifyEmail</span>(token); </span><br><span class="line"> <span class="keyword">if</span> (result.<span class="property">success</span>) <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 class="keyword">return</span> result; </span><br><span class="line"> } <span class="keyword">catch</span> (error) { </span><br><span class="line"> <span class="keyword">return</span> { </span><br><span class="line"> <span class="attr">success</span>: <span class="literal">false</span>, </span><br><span class="line"> <span class="attr">error</span>: error <span class="keyword">instanceof</span> <span class="title class_">Error</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">}, [verifyEmail]);</span><br></pre></td></tr></tbody></table></figure>
<p>该方法的主要目的是处理邮箱验证逻辑。通过 <code>token</code> 调用 <code>verifyEmail</code> 函数,判断验证是否成功,并在页面历史状态中作出相应的更新。</p>
<p>这里使用了 <code>useCallback</code> 进行性能优化,确保在依赖项未变化时,返回的函数引用不会发生变化。</p>
<p><code>window.history.replaceState</code> 方法则是替换了当前历史记录的状态。当用户被验证成功后,<code>token</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><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">const</span> <span class="title function_">handleResendVerification</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">const</span> response = <span class="keyword">await</span> <span class="title function_">resendVerificationEmail</span>(verificationUserId); </span><br><span class="line"> <span class="keyword">if</span> (response.<span class="property">success</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><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>
<p>这个函数的主要功能是为特定用户重新发送验证邮件。调用异步函数 <code>resendVerificationEmail</code> 成功后就会自动导航到登录页面。</p>
<p>添加两个 <code>useEffect</code> 钩子来管理验证流程:</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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 处理邮箱验证</span></span><br><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]); </span><br></pre></td></tr></tbody></table></figure>
<ol>
<li>使用 <code>URLSearchParams</code> 解析 <code>location.search</code>,获取查询参数中的 <code>token</code></li>
<li>在满足以下条件时调用 <code>handleVerification</code>:
<ul>
<li>URL 中存在 <code>token</code></li>
<li>当前未在进行验证</li>
<li>邮箱尚未验证成功</li>
<li>传入的 <code>token</code> 不等于当前用户的 <code>verificationToken</code></li>
</ul>
</li>
<li>调用 <code>handleVerification</code> 处理邮箱验证</li>
</ol>
<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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 处理验证成功后的跳转</span></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> (emailVerified && !isLoading) { </span><br><span class="line"> <span class="keyword">const</span> redirectTimer = <span class="built_in">setTimeout</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="number">1500</span>); </span><br><span class="line"> <span class="keyword">return</span> <span class="function">() =></span> <span class="built_in">clearTimeout</span>(redirectTimer); </span><br><span class="line"> } </span><br><span class="line">}, [emailVerified, isLoading, navigate]);</span><br></pre></td></tr></tbody></table></figure>
<ol>
<li>当 <code>emailVerified</code> 为 <code>true</code> 且 <code>isLoading</code> 为 <code>false</code> 时,触发跳转逻辑</li>
<li>使用 <code>setTimeout</code> 在 1.5 秒后执行 <code>navigate('/login')</code>,给用户留出视觉反馈时间</li>
<li>在组件卸载或依赖更新时,通过 <code>clearTimeout</code> 清除定时器,避免潜在内存泄漏或多余跳转</li>
</ol>
<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></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">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 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></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">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"> {/* 令牌过期状态 */}</span></span><br><span class="line"><span class="language-xml"> { !isLoading && verificationError === EmailVerificationError.TOKEN_EXPIRED && ( </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-4"</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">"alert alert-warning"</span>></span> </span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">svg</span>></span>...<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}<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">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-primary"</span> </span></span></span><br><span class="line"><span class="tag"><span class="language-xml"> <span class="attr">onClick</span>=<span class="string">{handleResendVerification}</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"> 重新发送验证邮件 </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></span><br><span class="line"><span class="language-xml"> {/* 已验证状态 */}</span></span><br><span class="line"><span class="language-xml"> {!isLoading && 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">svg</span>></span>...<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>邮箱验证成功!即将跳转到登录页面...<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 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></pre></td></tr></tbody></table></figure>
<h4 id="413-邮件验证逻辑"><a class="markdownIt-Anchor" href="#413-邮件验证逻辑"></a> 4.1.3. 邮件验证逻辑</h4>
<p>上面说到了几个我们并没有写的方法:<code>verifyEmail</code> 和 <code>resendVerificationEmail</code>。</p>
<p>现在我们要在 <code>actions.ts</code> 中完善这两个方法。</p>
<p>先在文件开头位置导入 <code>types.ts</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><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> { </span><br><span class="line"> <span class="title class_">User</span>, </span><br><span class="line"> <span class="title class_">UserState</span>, </span><br><span class="line"> <span class="title class_">LoginCredentials</span>, </span><br><span class="line"> <span class="title class_">RegisterCredentials</span>, </span><br><span class="line"> <span class="title class_">VerificationResponse</span> </span><br><span class="line">} <span class="keyword">from</span> <span class="string">'./types'</span>;</span><br></pre></td></tr></tbody></table></figure>
<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></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</span>) =></span> ({ </span><br><span class="line"> <span class="comment">// ...</span></span><br><span class="line"> <span class="attr">emailVerified</span>: <span class="literal">false</span>, </span><br><span class="line"> <span class="attr">verificationError</span>: <span class="literal">undefined</span>, </span><br><span class="line"> <span class="attr">verificationUserId</span>: <span class="literal">undefined</span>, </span><br><span class="line"> <span class="attr">verificationInProgress</span>: <span class="literal">false</span>, </span><br><span class="line"> <span class="attr">verificationToken</span>: <span class="literal">undefined</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><code>verifyEmail</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></pre></td><td class="code"><pre><span class="line"><span class="attr">verifyEmail</span>: <span class="title function_">async</span> (<span class="attr">token</span>: <span class="built_in">string</span>): <span class="title class_">Promise</span><<span class="title class_">VerificationResponse</span>> => {</span><br><span class="line"> <span class="comment">// 下面继续...</span></span><br><span class="line">},</span><br></pre></td></tr></tbody></table></figure>
<p><code>verifyEmail</code> 方法的目的在于确保邮箱有效且记录验证状态。</p>
<p>首先为了避免用户重复提交相同的 <code>token</code>、导致不必要的 API 调用,我们需要检查当前的状态:</p>
<ul>
<li>若 <code>verificationInProgress</code> 为 <code>true</code>,那么就提示用户「验证正在进行中」</li>
<li>若 <code>emailVerified</code> 为 <code>true</code> 且 <code>verificationToken</code> 匹配,那么直接返回成功信息,避免重复请求</li>
</ul>
<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></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> state = useUserStore.<span class="title function_">getState</span>(); </span><br><span class="line"><span class="keyword">if</span> (state.<span class="property">verificationInProgress</span> || (state.<span class="property">verificationToken</span> === token && state.<span class="property">emailVerified</span>)) { </span><br><span class="line"> <span class="keyword">return</span> { </span><br><span class="line"> <span class="attr">success</span>: state.<span class="property">emailVerified</span>, </span><br><span class="line"> <span class="attr">message</span>: state.<span class="property">emailVerified</span> ? <span class="string">'邮箱已验证'</span> : <span class="string">'验证正在进行中'</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"><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="title function_">set</span>({ </span><br><span class="line"> <span class="attr">isLoading</span>: <span class="literal">true</span>, </span><br><span class="line"> <span class="attr">error</span>: <span class="literal">null</span>, </span><br><span class="line"> <span class="attr">verificationInProgress</span>: <span class="literal">true</span>, </span><br><span class="line"> <span class="attr">verificationToken</span>: token </span><br><span class="line">});</span><br></pre></td></tr></tbody></table></figure>
<p>验证邮箱地址需要服务端支持,因此发送带 <code>token</code> 的 API 请求进行校验(我这里设计的服务端 API 是要 <code>GET</code><br>
的):</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">try</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">const</span> data = response.<span class="property">data</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>
<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></pre></td><td class="code"><pre><span class="line"><span class="keyword">try</span> {</span><br><span class="line"> <span class="comment">// ...</span></span><br><span class="line"></span><br><span class="line"> <span class="keyword">if</span> (data.<span class="property">success</span>) { </span><br><span class="line"> <span class="title function_">set</span>({ </span><br><span class="line"> <span class="attr">isLoading</span>: <span class="literal">false</span>, </span><br><span class="line"> <span class="attr">error</span>: <span class="literal">null</span>, </span><br><span class="line"> <span class="attr">emailVerified</span>: <span class="literal">true</span>, </span><br><span class="line"> <span class="attr">verificationError</span>: <span class="literal">undefined</span>, </span><br><span class="line"> <span class="attr">verificationUserId</span>: <span class="literal">undefined</span>, </span><br><span class="line"> <span class="attr">verificationInProgress</span>: <span class="literal">false</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>如果验证失败,那就保存失败信息、供用户查看,并允许用户再次尝试:</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="keyword">try</span> {</span><br><span class="line"> <span class="comment">// ...</span></span><br><span class="line"> <span class="keyword">else</span> {</span><br><span class="line"> <span class="title function_">set</span>({ </span><br><span class="line"> <span class="attr">isLoading</span>: <span class="literal">false</span>, </span><br><span class="line"> <span class="attr">error</span>: data.<span class="property">message</span>, </span><br><span class="line"> <span class="attr">emailVerified</span>: <span class="literal">false</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 class="attr">verificationInProgress</span>: <span class="literal">false</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>当然,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><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">catch</span> (error) { </span><br><span class="line"> <span class="keyword">const</span> errorMessage = 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 class="title function_">set</span>({ </span><br><span class="line"> <span class="attr">isLoading</span>: <span class="literal">false</span>, </span><br><span class="line"> <span class="attr">error</span>: errorMessage, </span><br><span class="line"> <span class="attr">emailVerified</span>: <span class="literal">false</span>, </span><br><span class="line"> <span class="attr">verificationInProgress</span>: <span class="literal">false</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">success</span>: <span class="literal">false</span>, </span><br><span class="line"> <span class="attr">message</span>: errorMessage </span><br><span class="line"> }; </span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
</li>
<li>
<p><code>resendVerificationEmail</code> 方法</p>
<p>和 <code>verifyEmail</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><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></pre></td><td class="code"><pre><span class="line"><span class="attr">resendVerificationEmail</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="title function_">set</span>({ <span class="attr">isLoading</span>: <span class="literal">true</span>, <span class="attr">error</span>: <span class="literal">null</span> }); </span><br><span class="line"> <span class="keyword">try</span> {</span><br><span class="line"> <span class="keyword">const</span> response = <span class="keyword">await</span> api.<span class="property">post</span><<span class="title class_">VerificationResponse</span>>(<span class="string">`/users/resend-verification/<span class="subst">${userId}</span>`</span>); </span><br><span class="line"> <span class="keyword">const</span> data = response.<span class="property">data</span>;</span><br><span class="line"></span><br><span class="line"> <span class="title function_">set</span>({ </span><br><span class="line"> <span class="attr">isLoading</span>: <span class="literal">false</span>, </span><br><span class="line"> <span class="attr">error</span>: data.<span class="property">success</span> ? <span class="literal">null</span> : data.<span class="property">message</span> </span><br><span class="line"> }); </span><br><span class="line"></span><br><span class="line"> <span class="keyword">return</span> data; </span><br><span class="line"> } <span class="keyword">catch</span> (error) { </span><br><span class="line"> <span class="keyword">const</span> errorMessage = 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 class="title function_">set</span>({ </span><br><span class="line"> <span class="attr">isLoading</span>: <span class="literal">false</span>, </span><br><span class="line"> <span class="attr">error</span>: errorMessage </span><br><span class="line"> }); </span><br><span class="line"></span><br><span class="line"> <span class="keyword">throw</span> error; </span><br><span class="line"> } </span><br><span class="line">}, </span><br></pre></td></tr></tbody></table></figure>
</li>
</ol>
<h2 id="42-后端实现"><a class="markdownIt-Anchor" href="#42-后端实现"></a> 4.2. 后端实现</h2>
<p>在实现用户注册和邮箱验证功能时,我们通常会面临以下几个关键问题:</p>
<ol>
<li>如何确保用户提供的邮箱是真实有效的?</li>
<li>如何防止垃圾注册和恶意用户?</li>
<li>如何安全地管理用户的验证状态?</li>
</ol>
<h4 id="421-用户实体扩展"><a class="markdownIt-Anchor" href="#421-用户实体扩展"></a> 4.2.1. 用户实体扩展</h4>
<p>为了支持邮箱验证功能,我们要在 <code>Users</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><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">@Column</span>({ <span class="attr">default</span>: <span class="literal">false</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 class="meta">@Column</span>({ <span class="attr">nullable</span>: <span class="literal">true</span> })</span><br><span class="line"><span class="attr">verificationToken</span>: <span class="built_in">string</span>;</span><br><span class="line"></span><br><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">verificationTokenExpires</span>: <span class="title class_">Date</span>;</span><br></pre></td></tr></tbody></table></figure>
<p>这三个新增字段解决了邮箱验证的核心需求:</p>
<ul>
<li><code>verified</code>:标记用户是否已验证邮箱</li>
<li><code>verificationToken</code>:存储唯一的验证令牌</li>
<li><code>verificationTokenExpires</code>:设置令牌的过期时间</li>
</ul>
<h4 id="422-用户服务中的邮箱验证逻辑"><a class="markdownIt-Anchor" href="#422-用户服务中的邮箱验证逻辑"></a> 4.2.2. 用户服务中的邮箱验证逻辑</h4>
<p>先在 <code>users.service.ts</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><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_">ConflictException</span>, <span class="title class_">Injectable</span>, <span class="title class_">NotFoundException</span> } <span class="keyword">from</span> <span class="string">'@nestjs/common'</span>;</span><br><span class="line"><span class="keyword">import</span> { v4 <span class="keyword">as</span> uuidv4 } <span class="keyword">from</span> <span class="string">'uuid'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">EmailService</span> } <span class="keyword">from</span> <span class="string">'../email/email.service'</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="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">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>
<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></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_">UsersService</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></span><br><span class="line"><span class="params"> <span class="meta">@InjectRepository</span>(Users)</span></span><br><span class="line"><span class="params"> <span class="keyword">private</span> <span class="attr">usersRepository</span>: <span class="title class_">Repository</span><<span class="title class_">Users</span>>,</span></span><br><span class="line"><span class="params"> <span class="keyword">private</span> <span class="attr">emailService</span>: <span class="title class_">EmailService</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><code>create</code> 方法用于创建一个用户。当一个用户尝试注册自己时,系统应当通过以下步骤确保注册过程的安全性和可靠性:</p>
<ol>
<li>
<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></pre></td><td class="code"><pre><span class="line"><span class="keyword">async</span> <span class="title function_">create</span>(<span class="attr">user</span>: <span class="title class_">CreateUserDto</span>): <span class="title class_">Promise</span><{ <span class="attr">id</span>: <span class="built_in">string</span>; <span class="attr">email</span>: <span class="built_in">string</span> }> {</span><br><span class="line"> <span class="keyword">const</span> existingUser = <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>: [{ <span class="attr">email</span>: user.<span class="property">email</span> }, { <span class="attr">username</span>: user.<span class="property">username</span> }]</span><br><span class="line"> });</span><br><span class="line"></span><br><span class="line"> <span class="keyword">if</span> (existingUser) {</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 class="subst">${user.email}</span> 的用户已存在`</span>);</span><br><span class="line"> <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">ConflictException</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><br></pre></td></tr></tbody></table></figure>
</li>
<li>
<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></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> hashedPassword = <span class="keyword">await</span> bcrypt.<span class="title function_">hash</span>(user.<span class="property">password</span>, <span class="number">10</span>);</span><br><span class="line"><span class="keyword">const</span> verificationToken = <span class="title function_">uuidv4</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> + verificationToken);</span><br></pre></td></tr></tbody></table></figure>
</li>
<li>
<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="keyword">const</span> newUser = <span class="variable language_">this</span>.<span class="property">usersRepository</span>.<span class="title function_">create</span>({</span><br><span class="line"> ...user,</span><br><span class="line"> <span class="attr">password</span>: hashedPassword,</span><br><span class="line"> verificationToken,</span><br><span class="line"> <span class="attr">verificationTokenExpires</span>: <span class="keyword">new</span> <span class="title class_">Date</span>(<span class="title class_">Date</span>.<span class="title function_">now</span>() + <span class="number">24</span> * <span class="number">60</span> * <span class="number">60</span> * <span class="number">1000</span>),</span><br><span class="line"> <span class="attr">verified</span>: <span class="literal">false</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_">info</span>(<span class="string">'用户已被保存:'</span> + <span class="title class_">JSON</span>.<span class="title function_">stringify</span>(newUser));</span><br></pre></td></tr></tbody></table></figure>
</li>
<li>
<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></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> savedUser = <span class="keyword">await</span> <span class="variable language_">this</span>.<span class="property">usersRepository</span>.<span class="title function_">save</span>(newUser);</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 class="title class_">JSON</span>.<span class="title function_">stringify</span>(savedUser));</span><br></pre></td></tr></tbody></table></figure>
</li>
<li>
<p>发送验证邮件:</p>
<figure class="highlight ts"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">await</span> <span class="variable language_">this</span>.<span class="property">emailService</span>.<span class="title function_">sendVerificationEmail</span>(savedUser);</span><br></pre></td></tr></tbody></table></figure>
</li>
<li>
<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></pre></td><td class="code"><pre><span class="line"><span class="keyword">return</span> {</span><br><span class="line"> <span class="attr">id</span>: savedUser.<span class="property">id</span>,</span><br><span class="line"> <span class="attr">email</span>: savedUser.<span class="property">email</span></span><br><span class="line">};</span><br></pre></td></tr></tbody></table></figure>
</li>
</ol>
</li>
<li>
<p><code>verifyEmail</code> 方法用于验证用户邮箱。当用户点击验证链接时,系统通过该方法完成验证流程。此方法通过检查令牌的有效性和状态,确保邮箱验证的安全性和可靠性:</p>
<ol>
<li>
<p>根据提供的 <code>token</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><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">async</span> <span class="title function_">verifyEmail</span>(<span class="attr">token</span>: <span class="built_in">string</span>): <span class="title class_">Promise</span><<span class="title class_">VerificationResponse</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">`接收到的 token:<span class="subst">${token}</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">usersRepository</span>.<span class="title function_">findOne</span>({</span><br><span class="line"> <span class="attr">where</span>: {</span><br><span class="line"> <span class="attr">verificationToken</span>: token</span><br><span class="line"> }</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="variable language_">this</span>.<span class="property">logger</span>.<span class="title function_">debug</span>(<span class="string">`验证失败:未找到对应 token 的用户,token:<span class="subst">${token}</span>`</span>);</span><br><span class="line"> <span class="keyword">return</span> {</span><br><span class="line"> <span class="attr">success</span>: <span class="literal">false</span>,</span><br><span class="line"> <span class="attr">message</span>: <span class="string">'无效的验证链接'</span>,</span><br><span class="line"> <span class="attr">error</span>: <span class="title class_">EmailVerificationError</span>.<span class="property">TOKEN_INVALID</span></span><br><span class="line"> };</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><br><span class="line"> <span class="string">`找到用户:<span class="subst">${user.email}</span>, 验证状态:<span class="subst">${user.verified}</span>, token 过期时间:<span class="subst">${user.verificationTokenExpires}</span>`</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><br></pre></td></tr></tbody></table></figure>
</li>
<li>
<p>检查用户的验证状态。这里会有多个条件:</p>
<ol>
<li>
<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="keyword">if</span> (user.<span class="property">verified</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">${user.email}</span> 已经验证过了`</span>);</span><br><span class="line"> <span class="keyword">return</span> {</span><br><span class="line"> <span class="attr">success</span>: <span class="literal">false</span>,</span><br><span class="line"> <span class="attr">message</span>: <span class="string">'邮箱已经验证过了'</span>,</span><br><span class="line"> <span class="attr">error</span>: <span class="title class_">EmailVerificationError</span>.<span class="property">ALREADY_VERIFIED</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"><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">if</span> (user.<span class="property">verificationTokenExpires</span> < <span class="keyword">new</span> <span class="title class_">Date</span>()) {</span><br><span class="line"> <span class="variable language_">this</span>.<span class="property">logger</span>.<span class="title function_">debug</span>(</span><br><span class="line"> <span class="string">`验证失败:token 已过期, 用户: <span class="subst">${user.email}</span>, `</span> +</span><br><span class="line"> <span class="string">`过期时间:<span class="subst">${user.verificationTokenExpires}</span>, 当前时间:<span class="subst">${<span class="keyword">new</span> <span class="built_in">Date</span>()}</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">success</span>: <span class="literal">false</span>,</span><br><span class="line"> <span class="attr">message</span>: <span class="string">'验证链接已过期,请重新发送验证邮件'</span>,</span><br><span class="line"> <span class="attr">error</span>: <span class="title class_">EmailVerificationError</span>.<span class="property">TOKEN_EXPIRED</span>,</span><br><span class="line"> <span class="attr">userId</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>
</li>
</ol>
</li>
<li>
<p>更新用户验证状态,将 <code>verified</code> 属性设置为 <code>true</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><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">user.<span class="property">verified</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">await</span> <span class="variable language_">this</span>.<span class="property">usersRepository</span>.<span class="title function_">save</span>(user);</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.email}</span> 验证成功,已更新验证状态`</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> error;</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
</li>
<li>
<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></pre></td><td class="code"><pre><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.email}</span> 验证成功`</span>);</span><br><span class="line"><span class="keyword">return</span> {</span><br><span class="line"> <span class="attr">success</span>: <span class="literal">true</span>,</span><br><span class="line"> <span class="attr">message</span>: <span class="string">'邮箱验证成功'</span></span><br><span class="line">};</span><br></pre></td></tr></tbody></table></figure>
</li>
</ol>
</li>
<li>
<p><code>resendVerificationEmail</code> 方法用于重新发送验证邮件。当用户请求重发验证邮件时,此方法处理生成新的验证令牌并发送邮件的整个流程,确保未验证用户能够完成邮箱验证:</p>
<ol>
<li>
<p>通过 <code>userId</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><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">async</span> <span class="title function_">resendVerificationEmail</span>(<span class="attr">userId</span>: <span class="built_in">string</span>): <span class="title class_">Promise</span><<span class="title class_">VerificationResponse</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="title function_">findById</span>(userId);</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.email}</span>, 当前验证状态:<span class="subst">${user.verified}</span>`</span>);</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 下面继续...</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"><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">if</span> (user.<span class="property">verified</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">${user.email}</span> 已经验证过了`</span>);</span><br><span class="line"> <span class="keyword">return</span> {</span><br><span class="line"> <span class="attr">success</span>: <span class="literal">false</span>,</span><br><span class="line"> <span class="attr">message</span>: <span class="string">'邮箱已经验证过了'</span>,</span><br><span class="line"> <span class="attr">error</span>: <span class="title class_">EmailVerificationError</span>.<span class="property">ALREADY_VERIFIED</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"><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">const</span> oldToken = user.<span class="property">verificationToken</span>;</span><br><span class="line">user.<span class="property">verificationToken</span> = <span class="title function_">uuidv4</span>();</span><br><span class="line">user.<span class="property">verificationTokenExpires</span> = <span class="keyword">new</span> <span class="title class_">Date</span>(<span class="title class_">Date</span>.<span class="title function_">now</span>() + <span class="number">24</span> * <span class="number">60</span> * <span class="number">60</span> * <span class="number">1000</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><br><span class="line"> <span class="string">`更新验证信息:用户:<span class="subst">${user.email}</span>, `</span> +</span><br><span class="line"> <span class="string">`旧 token:<span class="subst">${oldToken}</span>, `</span> +</span><br><span class="line"> <span class="string">`新 token:<span class="subst">${user.verificationToken}</span>, `</span> +</span><br><span class="line"> <span class="string">`过期时间:<span class="subst">${user.verificationTokenExpires}</span>`</span></span><br><span class="line">);</span><br></pre></td></tr></tbody></table></figure>
</li>
<li>
<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></pre></td><td class="code"><pre><span class="line"><span class="keyword">await</span> <span class="variable language_">this</span>.<span class="property">usersRepository</span>.<span class="title function_">save</span>(user);</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.email}</span>`</span>);</span><br></pre></td></tr></tbody></table></figure>
</li>
<li>
<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></pre></td><td class="code"><pre><span class="line"><span class="keyword">await</span> <span class="variable language_">this</span>.<span class="property">emailService</span>.<span class="title function_">sendVerificationEmail</span>(user);</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.email}</span>`</span>);</span><br></pre></td></tr></tbody></table></figure>
</li>
<li>
<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></pre></td><td class="code"><pre><span class="line"><span class="keyword">return</span> {</span><br><span class="line"> <span class="attr">success</span>: <span class="literal">true</span>,</span><br><span class="line"> <span class="attr">message</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 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">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">`重发验证邮件失败:userId=<span class="subst">${userId}</span>, error=<span class="subst">${error.message}</span>`</span>, error.<span class="property">stack</span>);</span><br><span class="line"> <span class="keyword">return</span> {</span><br><span class="line"> <span class="attr">success</span>: <span class="literal">false</span>,</span><br><span class="line"> <span class="attr">message</span>: <span class="string">'重新发送验证邮件失败'</span>,</span><br><span class="line"> <span class="attr">error</span>: <span class="title class_">EmailVerificationError</span>.<span class="property">TOKEN_INVALID</span></span><br><span class="line"> };</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
</li>
</ol>
</li>
</ol>
<h4 id="423-用户控制器新增接口"><a class="markdownIt-Anchor" href="#423-用户控制器新增接口"></a> 4.2.3. 用户控制器新增接口</h4>
<p>我们需要以下两个接口:</p>
<ol>
<li><code>GET /verify-email</code>:通过查询参数接收验证 <code>token</code>,完成用户邮箱验证</li>
<li><code>POST /resend-verification/:id</code>:接收用户 <code>id</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><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">@ApiOperation</span>({ <span class="attr">summary</span>: <span class="string">'验证用户邮箱'</span> })</span><br><span class="line"><span class="meta">@ApiQuery</span>({ <span class="attr">name</span>: <span class="string">'token'</span>, <span class="attr">description</span>: <span class="string">'验证token'</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><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">'verify-email'</span>)</span><br><span class="line"><span class="keyword">async</span> <span class="title function_">verifyEmail</span>(<span class="meta">@Query</span>(<span class="string">'token'</span>) <span class="attr">token</span>: <span class="built_in">string</span>): <span class="title class_">Promise</span><{ <span class="attr">message</span>: <span class="built_in">string</span> }> {</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_">verifyEmail</span>(token);</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">@ApiParam</span>({ <span class="attr">name</span>: <span class="string">'id'</span>, <span class="attr">description</span>: <span class="string">'用户ID'</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><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">'resend-verification/:id'</span>)</span><br><span class="line"><span class="keyword">async</span> <span class="title function_">resendVerification</span>(<span class="meta">@Param</span>(<span class="string">'id'</span>) <span class="attr">id</span>: <span class="built_in">string</span>): <span class="title class_">Promise</span><{ <span class="attr">message</span>: <span class="built_in">string</span> }> {</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_">resendVerificationEmail</span>(id);</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
<blockquote>
<p>关于 <code>HttpStatus</code> 和 <code>@HttpCode</code>,请见 <a href="#52-%E4%BF%AE%E6%94%B9-swagger-%E6%A0%87%E7%AD%BE">这里</a>。</p>
</blockquote>
<h4 id="424-开发邮件模块"><a class="markdownIt-Anchor" href="#424-开发邮件模块"></a> 4.2.4. 开发邮件模块</h4>
<p>安装邮件模块 <code>@nestjs-modules/mailer</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 @nestjs-modules/mailer</span><br></pre></td></tr></tbody></table></figure>
<p>在 <code>src</code> 目录下创建 <code>email</code> 目录,并在内创建 <code>email.module.ts</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><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></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_">MailerModule</span> } <span class="keyword">from</span> <span class="string">'@nestjs-modules/mailer'</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_">HandlebarsAdapter</span> } <span class="keyword">from</span> <span class="string">'@nestjs-modules/mailer/dist/adapters/handlebars.adapter'</span>;</span><br><span class="line"><span class="keyword">import</span> { join } <span class="keyword">from</span> <span class="string">'path'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">EmailService</span> } <span class="keyword">from</span> <span class="string">'./email.service'</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_">MailerModule</span>.<span class="title function_">forRootAsync</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="keyword">const</span> transport = {</span><br><span class="line"> <span class="attr">host</span>: configService.<span class="property">getOrThrow</span><<span class="built_in">string</span>>(<span class="string">'SMTP_HOST'</span>),</span><br><span class="line"> <span class="attr">port</span>: configService.<span class="property">getOrThrow</span><<span class="built_in">number</span>>(<span class="string">'SMTP_PORT'</span>),</span><br><span class="line"> <span class="attr">secure</span>: <span class="literal">false</span>,</span><br><span class="line"> <span class="attr">auth</span>: {</span><br><span class="line"> <span class="attr">user</span>: configService.<span class="property">getOrThrow</span><<span class="built_in">string</span>>(<span class="string">'SMTP_USER'</span>),</span><br><span class="line"> <span class="attr">pass</span>: configService.<span class="property">getOrThrow</span><<span class="built_in">string</span>>(<span class="string">'SMTP_PASS'</span>)</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"> transport,</span><br><span class="line"> <span class="attr">defaults</span>: {</span><br><span class="line"> <span class="attr">from</span>: <span class="string">`"Shopping Nest" <<span class="subst">${configService.getOrThrow<<span class="built_in">string</span>>(<span class="string">'SMTP_FROM_ADDRESS'</span>)}</span>>`</span></span><br><span class="line"> },</span><br><span class="line"> <span class="attr">template</span>: {</span><br><span class="line"> <span class="attr">dir</span>: <span class="title function_">join</span>(__dirname, <span class="string">'templates'</span>),</span><br><span class="line"> <span class="attr">adapter</span>: <span class="keyword">new</span> <span class="title class_">HandlebarsAdapter</span>(),</span><br><span class="line"> <span class="attr">options</span>: {</span><br><span class="line"> <span class="attr">strict</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="attr">inject</span>: [<span class="title class_">ConfigService</span>]</span><br><span class="line"> })</span><br><span class="line"> ],</span><br><span class="line"> <span class="attr">providers</span>: [<span class="title class_">EmailService</span>],</span><br><span class="line"> <span class="attr">exports</span>: [<span class="title class_">EmailService</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_">EmailModule</span> {}</span><br></pre></td></tr></tbody></table></figure>
<blockquote>
<p>我们需要在 <code>.env</code> 里新增几个值:</p>
<figure class="highlight plaintext"><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">SMTP_HOST=</span><br><span class="line">SMTP_PORT=</span><br><span class="line">SMTP_USER=</span><br><span class="line">SMTP_PASS=</span><br><span class="line">SMTP_FROM_ADDRESS=</span><br></pre></td></tr></tbody></table></figure>
<p>这些是常见的 SMTP(Simple Mail Transfer Protocol)配置参数,用于设置邮件服务器的基本信息以发送电子邮件。</p>
<ol>
<li>
<p><code>SMTP_HOST</code>:邮件服务器的主机地址,通常是邮件服务提供商的域名或 IP 地址(例如用 Gmail 的话就是写 <code>smtp.gmail.com</code>)</p>
</li>
<li>
<p><code>SMTP_PORT</code>:邮件服务器使用的端口号,用于建立与邮件服务器的连接</p>
<ul>
<li>常用端口:
<ul>
<li><code>587</code>:用于明文连接后升级为加密连接(STARTTLS)</li>
<li><code>465</code>:用于加密连接(SSL / TLS)</li>
<li><code>25</code>:传统的 SMTP 端口,可能被 ISP 限制</li>
</ul>
</li>
<li><code>587</code> 或 <code>465</code> 是现代邮件服务中最常见的选择</li>
</ul>
</li>
<li>
<p><code>SMTP_USER</code>:用于身份验证的用户名,通常是发送邮件的邮箱地址</p>
</li>
<li>
<p><code>SMTP_PASS</code>:与 <code>SMTP_USER</code> 对应的密码,用于 SMTP 身份验证(一些服务 —— 如 Gmail—— 可能要求使用应用专用密码而不是账户密码)</p>
</li>
<li>
<p><code>SMTP_FROM_ADDRESS</code>:邮件发送的来源地址,显示为邮件的 <code>From</code> 字段,通常是一个经过认证的邮箱地址</p>
<ul>
<li><code>no-reply@yourdomain.com</code>(用于自动邮件)</li>
<li><code>support@yourdomain.com</code>(用于支持邮件)</li>
<li>有些服务可能要求 <code>SMTP_FROM_ADDRESS</code> 必须与 <code>SMTP_USER</code> 保持一致(我使用的 MailGun 就是如此)</li>
</ul>
</li>
</ol>
</blockquote>
<blockquote>
<p>Handlebars 是一个简单但强大的模板语言,允许我们通过 <code>{{变量}}</code> 语法轻松插入动态内容。它支持部分模板(Partials),让我们可以模块化地构建电子邮件模板。</p>
<p>部分模版示例:</p>
<figure class="highlight hbs"><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="language-xml"><span class="tag"><<span class="name">header</span> <span class="attr">class</span>=<span class="string">"email-header"</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">div</span> <span class="attr">class</span>=<span class="string">"logo"</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">"</span></span></span><span class="template-variable">{{ <span class="name">base64Image</span> }}</span><span class="language-xml"><span class="tag"><span class="string">"</span> <span class="attr">alt</span>=<span class="string">"Logo"</span> <span class="attr">style</span>=<span class="string">"max-height: 50px;"</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">header</span>></span></span></span><br></pre></td></tr></tbody></table></figure>
<p>我们希望邮件的内容是动态生成的,并且邮件的布局或样式能够灵活调整,那么就需要结合模板引擎来渲染邮件内容。</p>
</blockquote>
<p>接着创建 <code>email.service.ts</code>,它会封装邮件发送的所有复杂逻辑:</p>
<ol>
<li>
<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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> { <span class="title class_">Injectable</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_">MailerService</span> } <span class="keyword">from</span> <span class="string">'@nestjs-modules/mailer'</span>;</span><br><span class="line"><span class="keyword">import</span> { readFileSync, existsSync } <span class="keyword">from</span> <span class="string">'fs'</span>;</span><br><span class="line"><span class="keyword">import</span> { join, resolve } <span class="keyword">from</span> <span class="string">'path'</span>;</span><br><span class="line"><span class="keyword">import</span> { execSync } <span class="keyword">from</span> <span class="string">'child_process'</span>;</span><br><span class="line"><span class="keyword">import</span> * <span class="keyword">as</span> <span class="title class_">HandleBars</span> <span class="keyword">from</span> <span class="string">'handlebars'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">Users</span> } <span class="keyword">from</span> <span class="string">'../entities/users.entity'</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_">EmailService</span> {</span><br><span class="line"> <span class="keyword">private</span> <span class="keyword">readonly</span> logger = winstonLogger;</span><br><span class="line"> <span class="keyword">private</span> <span class="keyword">readonly</span> <span class="attr">templatesDir</span>: <span class="built_in">string</span>;</span><br><span class="line"> <span class="keyword">private</span> <span class="keyword">readonly</span> <span class="attr">partialsDir</span>: <span class="built_in">string</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>
</li>
<li>
<p>在构造函数中注入 <code>ConfigService</code> 和 <code>MailerService</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><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_">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">mailerService</span>: <span class="title class_">MailerService</span></span></span><br><span class="line"><span class="params"> </span>) {</span><br><span class="line"> <span class="variable language_">this</span>.<span class="property">templatesDir</span> = <span class="title function_">resolve</span>(__dirname, <span class="string">'templates'</span>);</span><br><span class="line"> <span class="variable language_">this</span>.<span class="property">partialsDir</span> = <span class="title function_">resolve</span>(<span class="variable language_">this</span>.<span class="property">templatesDir</span>, <span class="string">'partials'</span>);</span><br><span class="line"> }</span><br></pre></td></tr></tbody></table></figure>
</li>
<li>
<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></pre></td><td class="code"><pre><span class="line"><span class="keyword">async</span> <span class="title function_">onModuleInit</span>(<span class="params"></span>) {</span><br><span class="line"> <span class="variable language_">this</span>.<span class="title function_">ensureTemplatesExist</span>();</span><br><span class="line"> <span class="keyword">await</span> <span class="variable language_">this</span>.<span class="title function_">registerPartials</span>();</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
<ul>
<li><code>onModuleInit</code> 方法会在模块初始化时被调用</li>
<li><code>ensureTemplatesExist</code> 方法应当是检查模板是否存在,如果不存在则通过执行命令 `yarn copy-templates 来复制模板文件</li>
<li><code>registerPartials</code> 方法则用于注册模板中的部分文件,确保这些部分模板可以在主模板中引用</li>
</ul>
</li>
<li>
<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></pre></td><td class="code"><pre><span class="line"><span class="keyword">private</span> <span class="title function_">ensureTemplatesExist</span>(<span class="params"></span>) {</span><br><span class="line"> <span class="keyword">if</span> (!<span class="title function_">existsSync</span>(<span class="variable language_">this</span>.<span class="property">templatesDir</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">'dist 中无法找到 templates,复制中...'</span>);</span><br><span class="line"> <span class="title function_">execSync</span>(<span class="string">'yarn copy-templates'</span>);</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
<p>该方法检查模板文件夹是否存在。如果文件夹不存在,则执行命令将模板文件复制过来。</p>
<p>这确保了在构建后的 <code>dist</code> 文件夹中也能找到模板文件。</p>
<blockquote>
<p>这需要在 <code>package.json</code> 里定义一条命令:</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="attr">"scripts"</span><span class="punctuation">:</span> <span class="punctuation">{</span></span><br><span class="line"> <span class="comment">// ... </span></span><br><span class="line"> <span class="attr">"copy-templates"</span><span class="punctuation">:</span> <span class="string">"copyfiles -u 3 src/email/templates/**/* dist/email/templates"</span></span><br><span class="line"><span class="punctuation">}</span></span><br></pre></td></tr></tbody></table></figure>
<blockquote>
<p><code>copyfiles</code> 用法:</p>
<ol>
<li>
<p>安装 <code>copyfiles</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 -D copyfiles</span><br></pre></td></tr></tbody></table></figure>
</li>
<li>
<p>如果是复制 <code>src</code> 下的所有 <code>.js</code> 文件到 <code>dist</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 copyfiles <span class="string">'src/**/*.js'</span> dist/</span><br></pre></td></tr></tbody></table></figure>
</li>
<li>
<p>不保留目录结构:</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 copyfiles -f <span class="string">'src/**/*.js'</span> dist/</span><br></pre></td></tr></tbody></table></figure>
</li>
</ol>
</blockquote>
<p><code>copyfiles -u 3 src/email/templates/**/* dist/email/templates</code> 的含义是将 <code>src/email/templates/</code> 文件夹下的所有文件和子文件夹复制到 <code>dist/email/templates/</code>,并通过 <code>-u 3</code> 参数调整复制时的目标路径结构。</p>
<p>其中 <code>-u 3</code> 表示忽略源路径中从末尾数起的 3 个目录层级,也就是不包含这些层级到目标路径中。</p>
</blockquote>
<p><code>ensureTemplatesExist</code> 方法解决了 NestJS 项目中、构建过程不会自动复制静态资源文件的问题。</p>
<p>当执行 <code>yarn build</code> 时,TypeScript 编译器只处理 <code>.ts</code> 文件。静态模板和资源不会自动复制到 <code>dist</code> 目录。</p>
</li>
<li>
<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><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">private</span> <span class="keyword">async</span> <span class="title function_">registerPartials</span>(<span class="params"></span>) {</span><br><span class="line"> <span class="keyword">const</span> partials = [</span><br><span class="line"> { <span class="attr">name</span>: <span class="string">'header'</span>, <span class="attr">file</span>: <span class="string">'header.hbs'</span> },</span><br><span class="line"> { <span class="attr">name</span>: <span class="string">'footer'</span>, <span class="attr">file</span>: <span class="string">'footer.hbs'</span> },</span><br><span class="line"> { <span class="attr">name</span>: <span class="string">'styles'</span>, <span class="attr">file</span>: <span class="string">'styles.hbs'</span> }</span><br><span class="line"> ];</span><br><span class="line"></span><br><span class="line"> <span class="keyword">for</span> (<span class="keyword">const</span> partial <span class="keyword">of</span> partials) {</span><br><span class="line"> <span class="keyword">const</span> partialPath = <span class="title function_">join</span>(<span class="variable language_">this</span>.<span class="property">partialsDir</span>, partial.<span class="property">file</span>);</span><br><span class="line"></span><br><span class="line"> <span class="keyword">if</span> (!<span class="title function_">existsSync</span>(partialPath)) {</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">${partialPath}</span>`</span>);</span><br><span class="line"> <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">Error</span>(<span class="string">`找不到部分模板文件:<span class="subst">${partial.file}</span>`</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> template = <span class="title function_">readFileSync</span>(partialPath, <span class="string">'utf8'</span>);</span><br><span class="line"> <span class="title class_">HandleBars</span>.<span class="title function_">registerPartial</span>(partial.<span class="property">name</span>, template);</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">${partial.name}</span>`</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">${partial.name}</span>`</span>, {</span><br><span class="line"> <span class="attr">error</span>: error.<span class="property">message</span>,</span><br><span class="line"> <span class="attr">stack</span>: error.<span class="property">stack</span>,</span><br><span class="line"> <span class="attr">path</span>: partialPath</span><br><span class="line"> });</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>
<p>在模板中,通常会有一些公共部分(如页头、页脚、样式等),这些部分可以通过 Handlebars 的 <code>registerPartial</code> 方法来注册,之后可以在主模板中调用。</p>
<p>这里的三个 <code>.hbs</code> 文件会在后面完善。</p>
</li>
<li>
<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></pre></td><td class="code"><pre><span class="line"><span class="keyword">private</span> <span class="keyword">async</span> <span class="title function_">compileTemplate</span>(<span class="attr">templateName</span>: <span class="built_in">string</span>, <span class="attr">context</span>: <span class="built_in">any</span>): <span class="title class_">Promise</span><<span class="built_in">string</span>> {</span><br><span class="line"> <span class="keyword">const</span> templatePath = <span class="title function_">join</span>(<span class="variable language_">this</span>.<span class="property">templatesDir</span>, <span class="string">`<span class="subst">${templateName}</span>.hbs`</span>);</span><br><span class="line"></span><br><span class="line"> <span class="keyword">if</span> (!<span class="title function_">existsSync</span>(templatePath)) {</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">${templatePath}</span>`</span>);</span><br><span class="line"> <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">Error</span>(<span class="string">`找不到模板文件:<span class="subst">${templateName}</span>.hbs`</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> templateContent = <span class="title function_">readFileSync</span>(templatePath, <span class="string">'utf8'</span>);</span><br><span class="line"> <span class="keyword">const</span> template = <span class="title class_">HandleBars</span>.<span class="title function_">compile</span>(templateContent);</span><br><span class="line"> <span class="keyword">return</span> <span class="title function_">template</span>(context);</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>, {</span><br><span class="line"> <span class="attr">error</span>: error.<span class="property">message</span>,</span><br><span class="line"> <span class="attr">stack</span>: error.<span class="property">stack</span>,</span><br><span class="line"> <span class="attr">template</span>: templateName,</span><br><span class="line"> <span class="attr">path</span>: templatePath</span><br><span class="line"> });</span><br><span class="line"> <span class="keyword">throw</span> error;</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
<p><code>compileTemplate</code> 方法用于读取模板文件并将其编译为 HTML 内容。它通过 <code>Handlebars.compile</code> 将模板文件内容编译为渲染函数,然后使用传入的 <code>context</code> 对象(包含动态内容)来渲染模板。</p>
</li>
<li>
<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><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_">sendVerificationEmail</span>(<span class="params"><span class="attr">user</span>: <span class="title class_">Users</span></span>) {</span><br><span class="line"> <span class="keyword">const</span> logoPath = <span class="title function_">join</span>(__dirname, <span class="string">'../assets/ShoppingNest.png'</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">'Logo Path:'</span> + logoPath);</span><br><span class="line"> <span class="keyword">const</span> logoBase64 = <span class="title function_">readFileSync</span>(logoPath).<span class="title function_">toString</span>(<span class="string">'base64'</span>);</span><br><span class="line"> <span class="keyword">const</span> logoMimeType = <span class="string">'image/png'</span>;</span><br><span class="line"> <span class="keyword">const</span> base64Image = <span class="string">`data:<span class="subst">${logoMimeType}</span>;base64,<span class="subst">${logoBase64}</span>`</span>;</span><br><span class="line"></span><br><span class="line"> <span class="keyword">const</span> frontendUrl = <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">'CORS_ORIGIN'</span>);</span><br><span class="line"> <span class="keyword">const</span> verificationUrl = <span class="string">`<span class="subst">${frontendUrl}</span>/verify-email?token=<span class="subst">${user.verificationToken}</span>`</span>;</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> html = <span class="keyword">await</span> <span class="variable language_">this</span>.<span class="title function_">compileTemplate</span>(<span class="string">'verification'</span>, {</span><br><span class="line"> <span class="attr">username</span>: user.<span class="property">username</span>,</span><br><span class="line"> verificationUrl,</span><br><span class="line"> <span class="attr">expiresIn</span>: <span class="string">'24小时'</span>,</span><br><span class="line"> <span class="attr">year</span>: <span class="keyword">new</span> <span class="title class_">Date</span>().<span class="title function_">getFullYear</span>(),</span><br><span class="line"> base64Image</span><br><span class="line"> });</span><br><span class="line"></span><br><span class="line"> <span class="keyword">await</span> <span class="variable language_">this</span>.<span class="property">mailerService</span>.<span class="title function_">sendMail</span>({</span><br><span class="line"> <span class="attr">to</span>: user.<span class="property">email</span>,</span><br><span class="line"> <span class="attr">subject</span>: <span class="string">'验证你的邮箱地址'</span>,</span><br><span class="line"> html</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.email}</span>`</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>, {</span><br><span class="line"> <span class="attr">error</span>: error.<span class="property">message</span>,</span><br><span class="line"> <span class="attr">stack</span>: error.<span class="property">stack</span>,</span><br><span class="line"> <span class="attr">user</span>: user.<span class="property">email</span></span><br><span class="line"> });</span><br><span class="line"> <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">Error</span>(<span class="string">`发送验证邮件失败:<span class="subst">${error.message}</span>`</span>);</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
<p>首先,读取图片文件并将其转换为 base64 格式,之后拼接验证链接,并使用 Handlebars 模板渲染邮件内容。</p>
<p>最后调用 <code>MailerService.sendMail</code> 发送邮件。</p>
<blockquote>
<p>图片位置被我写在了 <code>src/assets</code> 目录中。但是它和 <code>src/email/templates</code> 目录一样,有着不会被自动复制到 <code>dist</code> 目录的问题。</p>
<p>解决方法也很简单,同样在 <code>package.json</code> 中定义一个命令:</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="attr">"scripts"</span><span class="punctuation">:</span> <span class="punctuation">{</span></span><br><span class="line"> <span class="comment">// ...</span></span><br><span class="line"> <span class="attr">"copy-assets"</span><span class="punctuation">:</span> <span class="string">"copyfiles -u 2 src/assets/**/* dist/assets"</span></span><br><span class="line"><span class="punctuation">}</span></span><br></pre></td></tr></tbody></table></figure>
<p>然后在 <code>app.service.ts</code> 被初始化时调用(也可以在 <code>email.service.ts</code> 中调用,只是我认为 <code>src/assets</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><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="keyword">async</span> <span class="title function_">onModuleInit</span>(<span class="params"></span>) {</span><br><span class="line"> <span class="variable language_">this</span>.<span class="title function_">ensureAssetsExist</span>();</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="keyword">private</span> <span class="title function_">ensureAssetsExist</span>(<span class="params"></span>) {</span><br><span class="line"> <span class="keyword">const</span> assetsPath = <span class="title function_">resolve</span>(__dirname, <span class="string">'assets'</span>);</span><br><span class="line"> <span class="keyword">if</span> (!<span class="title function_">existsSync</span>(assetsPath)) {</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">'dist 中无法找到 assets,复制中...'</span>);</span><br><span class="line"> <span class="title function_">execSync</span>(<span class="string">'yarn copy-assets'</span>);</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
</blockquote>
</li>
</ol>
<p>现在我们可以开始写验证邮件的实际内容了:</p>
<ol>
<li>
<p>在 <code>src/email</code> 目录下创建 <code>templates</code> 目录,并创建 <code>verification.hbs</code>,作为验证邮件的主体模板:</p>
<figure class="highlight hbs"><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="language-xml"><span class="meta"><!DOCTYPE <span class="keyword">html</span>></span></span></span><br><span class="line"><span class="language-xml"><span class="tag"><<span class="name">html</span> <span class="attr">lang</span>=<span class="string">"zh"</span>></span></span></span><br><span class="line"><span class="language-xml"><span class="tag"><<span class="name">head</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">meta</span> <span class="attr">charset</span>=<span class="string">"utf-8"</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">meta</span> <span class="attr">name</span>=<span class="string">"viewport"</span> <span class="attr">content</span>=<span class="string">"width=device-width, initial-scale=1.0"</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">title</span>></span>验证你的邮箱地址<span class="tag"></<span class="name">title</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">style</span>></span></span></span><br><span class="line"><span class="language-xml"> </span><span class="template-variable">{{> <span class="name">styles</span>}}</span><span class="language-xml"></span></span><br><span class="line"><span class="language-xml"> <span class="tag"></<span class="name">style</span>></span></span></span><br><span class="line"><span class="language-xml"><span class="tag"></<span class="name">head</span>></span></span></span><br><span class="line"><span class="language-xml"><span class="tag"><<span class="name">body</span>></span></span></span><br><span class="line"><span class="language-xml"></span><span class="template-variable">{{> <span class="name">header</span>}}</span><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">div</span> <span class="attr">class</span>=<span class="string">"email-content"</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">h2</span>></span>您好,</span><span class="template-variable">{{<span class="name">username</span>}}</span><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">p</span>></span>感谢您注册我们的服务。请点击下面的按钮验证您的邮箱地址:<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 class="attr">class</span>=<span class="string">"button-container"</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">"</span></span></span><span class="template-variable">{{<span class="name">verificationUrl</span>}}</span><span class="language-xml"><span class="tag"><span class="string">"</span> <span class="attr">class</span>=<span class="string">"verify-button"</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 class="language-xml"></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">p</span> <span class="attr">class</span>=<span class="string">"verification-url"</span>></span></span><span class="template-variable">{{<span class="name">verificationUrl</span>}}</span><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"> <span class="tag"><<span class="name">p</span>></span>此验证链接将在</span><span class="template-variable">{{<span class="name">expiresIn</span>}}</span><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>如果您没有注册我们的服务,请忽略此邮件。<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 class="template-variable">{{> <span class="name">footer</span>}}</span><span class="language-xml"></span></span><br><span class="line"><span class="language-xml"><span class="tag"></<span class="name">body</span>></span></span></span><br><span class="line"><span class="language-xml"><span class="tag"></<span class="name">html</span>></span></span></span><br></pre></td></tr></tbody></table></figure>
<p>这里使用了三个动态内容插值:</p>
<ol>
<li><code>{{username}}</code>:动态填充用户名称</li>
<li><code>{{verification}}</code>:用户唯一的验证链接</li>
<li><code>{{expiresIn}}</code>:链接有效期提示</li>
</ol>
</li>
<li>
<p>在 <code>src/email/templates</code> 目录下创建 <code>partials</code> 目录,并在内创建 <code>header.hbs</code>:</p>
<figure class="highlight hbs"><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="language-xml"><span class="tag"><<span class="name">header</span> <span class="attr">class</span>=<span class="string">"email-header"</span>></span></span></span><br><span class="line"><span class="language-xml"><span class="tag"><<span class="name">div</span> <span class="attr">class</span>=<span class="string">"logo"</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">"</span></span></span><span class="template-variable">{{ <span class="name">base64Image</span> }}</span><span class="language-xml"><span class="tag"><span class="string">"</span> <span class="attr">alt</span>=<span class="string">"Logo"</span> <span class="attr">style</span>=<span class="string">"max-height: 50px;"</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">header</span>></span></span></span><br></pre></td></tr></tbody></table></figure>
<p>这里的 <code>style</code> 可以根据自己的 Logo 图片进行调整。</p>
<blockquote>
<p>其实还有一种方法,是在前端项目中的 <code>public</code> 目录里放 Logo 图片,然后这里直接调用 <code>前端链接/图片文件名称.图片文件类型</code>。</p>
<p>但是这意味着如果前端项目是在本地环境运行的,图片链接便无效。</p>
</blockquote>
</li>
<li>
<p>在 <code>src/email/templates/partials</code> 目录下创建 <code>footer.hbs</code>:</p>
<figure class="highlight hbs"><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="language-xml"><span class="tag"><<span class="name">footer</span> <span class="attr">class</span>=<span class="string">"email-footer"</span>></span></span></span><br><span class="line"><span class="language-xml"> <span class="tag"><<span class="name">p</span>></span>© </span><span class="template-variable">{{<span class="name">year</span>}}</span><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>此邮件由系统自动发送,请勿回复。<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">footer</span>></span></span></span><br></pre></td></tr></tbody></table></figure>
<p>这里可以自行修改。</p>
</li>
<li>
<p>在 <code>src/email/templates/partials</code> 目录下创建 <code>styles.hbs</code>:</p>
<figure class="highlight hbs"><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></pre></td><td class="code"><pre><span class="line"><span class="language-xml">body {</span></span><br><span class="line"><span class="language-xml"> margin: 0;</span></span><br><span class="line"><span class="language-xml"> padding: 0;</span></span><br><span class="line"><span class="language-xml"> font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;</span></span><br><span class="line"><span class="language-xml"> line-height: 1.6;</span></span><br><span class="line"><span class="language-xml"> color: #333;</span></span><br><span class="line"><span class="language-xml"> background-color: #f9f9f9;</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">.email-header {</span></span><br><span class="line"><span class="language-xml"> text-align: center;</span></span><br><span class="line"><span class="language-xml"> padding: 20px;</span></span><br><span class="line"><span class="language-xml"> background-color: #ffffff;</span></span><br><span class="line"><span class="language-xml"> border-bottom: 1px solid #eee;</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">.email-content {</span></span><br><span class="line"><span class="language-xml"> max-width: 600px;</span></span><br><span class="line"><span class="language-xml"> margin: 0 auto;</span></span><br><span class="line"><span class="language-xml"> padding: 20px;</span></span><br><span class="line"><span class="language-xml"> background-color: #ffffff;</span></span><br><span class="line"><span class="language-xml"> border-radius: 8px;</span></span><br><span class="line"><span class="language-xml"> box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);</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">.button-container {</span></span><br><span class="line"><span class="language-xml"> text-align: center;</span></span><br><span class="line"><span class="language-xml"> margin: 30px 0;</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">.verify-button {</span></span><br><span class="line"><span class="language-xml"> display: inline-block;</span></span><br><span class="line"><span class="language-xml"> padding: 12px 24px;</span></span><br><span class="line"><span class="language-xml"> background-color: #4CAF50;</span></span><br><span class="line"><span class="language-xml"> color: #ffffff;</span></span><br><span class="line"><span class="language-xml"> text-decoration: none;</span></span><br><span class="line"><span class="language-xml"> border-radius: 5px;</span></span><br><span class="line"><span class="language-xml"> font-weight: bold;</span></span><br><span class="line"><span class="language-xml"> transition: background-color 0.3s;</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">.verify-button:hover {</span></span><br><span class="line"><span class="language-xml"> background-color: #45a049;</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">.verification-url {</span></span><br><span class="line"><span class="language-xml"> word-break: break-all;</span></span><br><span class="line"><span class="language-xml"> padding: 10px;</span></span><br><span class="line"><span class="language-xml"> background-color: #f5f5f5;</span></span><br><span class="line"><span class="language-xml"> border-radius: 4px;</span></span><br><span class="line"><span class="language-xml"> font-family: monospace;</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">.email-footer {</span></span><br><span class="line"><span class="language-xml"> text-align: center;</span></span><br><span class="line"><span class="language-xml"> padding: 20px;</span></span><br><span class="line"><span class="language-xml"> color: #666;</span></span><br><span class="line"><span class="language-xml"> font-size: 0.9em;</span></span><br><span class="line"><span class="language-xml">}</span></span><br></pre></td></tr></tbody></table></figure>
<p>同样可以自行修改。</p>
</li>
</ol>
<p>最后便是在各个模块中进行引用:</p>
<ol>
<li>
<p><code>users.module.ts</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><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_">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_">TypeOrmModule</span> } <span class="keyword">from</span> <span class="string">'@nestjs/typeorm'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">UsersController</span> } <span class="keyword">from</span> <span class="string">'./users.controller'</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.service'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">Users</span> } <span class="keyword">from</span> <span class="string">'../entities/users.entity'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">EmailModule</span> } <span class="keyword">from</span> <span class="string">'../email/email.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 class="title class_">TypeOrmModule</span>.<span class="title function_">forFeature</span>([<span class="title class_">Users</span>]), <span class="title class_">EmailModule</span>],</span><br><span class="line"> <span class="attr">controllers</span>: [<span class="title class_">UsersController</span>],</span><br><span class="line"> <span class="attr">providers</span>: [<span class="title class_">UsersService</span>],</span><br><span class="line"> <span class="attr">exports</span>: [<span class="title class_">UsersService</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_">UsersModule</span> {}</span><br></pre></td></tr></tbody></table></figure>
</li>
<li>
<p><code>app.module.ts</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><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="comment">// ...</span></span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">EmailModule</span> } <span class="keyword">from</span> <span class="string">'./email/email.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_">ConfigModule</span>.<span class="title function_">forRoot</span>({</span><br><span class="line"> <span class="attr">isGlobal</span>: <span class="literal">true</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 class="attr">CORS_ORIGIN</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="comment">// SMTP 配置</span></span><br><span class="line"> <span class="attr">SMTP_HOST</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">SMTP_PORT</span>: <span class="title class_">Joi</span>.<span class="title function_">number</span>().<span class="title function_">required</span>(),</span><br><span class="line"> <span class="attr">SMTP_USER</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">SMTP_PASS</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">SMTP_FROM_ADDRESS</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><br><span class="line"> }),</span><br><span class="line"> <span class="comment">// ...</span></span><br><span class="line"> <span class="title class_">EmailModule</span></span><br><span class="line"> ]</span><br><span class="line">})</span><br></pre></td></tr></tbody></table></figure>
</li>
</ol>
<h1 id="5-其他"><a class="markdownIt-Anchor" href="#5-其他"></a> 5. 其他</h1>
<h2 id="51-修改-http-exceptionfilterts"><a class="markdownIt-Anchor" href="#51-修改-http-exceptionfilterts"></a> 5.1. 修改 <code>http-exception.filter.ts</code></h2>
<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><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_">ExceptionFilter</span>, <span class="title class_">Catch</span>, <span class="title class_">ArgumentsHost</span>, <span class="title class_">HttpException</span>, <span class="title class_">HttpStatus</span> } <span class="keyword">from</span> <span class="string">'@nestjs/common'</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">@Catch</span>()</span><br><span class="line"><span class="keyword">export</span> <span class="keyword">class</span> <span class="title class_">HttpExceptionFilter</span> <span class="keyword">implements</span> <span class="title class_">ExceptionFilter</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="keyword">catch</span>(<span class="attr">exception</span>: <span class="built_in">unknown</span>, <span class="attr">host</span>: <span class="title class_">ArgumentsHost</span>) {</span><br><span class="line"> <span class="keyword">const</span> ctx = host.<span class="title function_">switchToHttp</span>();</span><br><span class="line"> <span class="keyword">const</span> response = ctx.<span class="title function_">getResponse</span>();</span><br><span class="line"> <span class="keyword">const</span> request = ctx.<span class="title function_">getRequest</span>();</span><br><span class="line"> <span class="keyword">const</span> status = exception <span class="keyword">instanceof</span> <span class="title class_">HttpException</span> ? exception.<span class="title function_">getStatus</span>() : <span class="title class_">HttpStatus</span>.<span class="property">INTERNAL_SERVER_ERROR</span>;</span><br><span class="line"></span><br><span class="line"> <span class="keyword">const</span> errorResponse = {</span><br><span class="line"> <span class="attr">statusCode</span>: status,</span><br><span class="line"> <span class="attr">timestamp</span>: <span class="keyword">new</span> <span class="title class_">Date</span>().<span class="title function_">toISOString</span>(),</span><br><span class="line"> <span class="attr">path</span>: request.<span class="property">url</span>,</span><br><span class="line"> <span class="attr">message</span>: exception <span class="keyword">instanceof</span> <span class="title class_">HttpException</span> ? exception.<span class="title function_">getResponse</span>() : <span class="string">'Internal server error'</span></span><br><span class="line"> };</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 用 winstonLogger 输出异常</span></span><br><span class="line"> <span class="keyword">if</span> (exception <span class="keyword">instanceof</span> <span class="title class_">HttpException</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">`HTTP 异常:<span class="subst">${<span class="built_in">JSON</span>.stringify(errorResponse)}</span>`</span>);</span><br><span class="line"> } <span class="keyword">else</span> {</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">${exception}</span>`</span>);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> response.<span class="title function_">status</span>(status).<span class="title function_">json</span>(errorResponse);</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure>
<h2 id="52-修改-swagger-标签"><a class="markdownIt-Anchor" href="#52-修改-swagger-标签"></a> 5.2. 修改 Swagger 标签</h2>
<p>从</p>
<figure class="highlight ts"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@ApiResponse</span>({ <span class="attr">status</span>: <span class="number">200</span>, <span class="attr">description</span>: <span class="string">'xxx'</span> })</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></pre></td><td class="code"><pre><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">'xxx'</span> })</span><br><span class="line"><span class="meta">@HttpCode</span>(<span class="title class_">HttpStatus</span>.<span class="property">OK</span>)</span><br></pre></td></tr></tbody></table></figure>
<p>状态码本身是数字,对于大多数人而言并不直观、需要对其有较深的理解。若需要统一调整状态码(如规范化状态码的使用),代码中可能需要逐一查找和替换。更何况状态码本身也更容易被误写为其他数字,难以自动检测错误。</p>
<p>而枚举值提供了清晰的语义,直接描述了状态码的含义,因此更容易理解。当需要统一调整时,可以直接通过 IDE 的自动补全功能快速选择合适的状态码。</p>
<p>以下为对应:</p>
<table>
<thead>
<tr>
<th>HTTP 状态码</th>
<th><code>HttpStatus</code> 枚举值</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>200</code></td>
<td><code>OK</code></td>
</tr>
<tr>
<td><code>201</code></td>
<td><code>CREATED</code></td>
</tr>
<tr>
<td><code>204</code></td>
<td><code>NO_CONTENT</code></td>
</tr>
<tr>
<td><code>400</code></td>
<td><code>BAD_REQUEST</code></td>
</tr>
<tr>
<td><code>401</code></td>
<td><code>UNAUTHORIZED</code></td>
</tr>
<tr>
<td><code>404</code></td>
<td><code>NOT_FOUND</code></td>
</tr>
<tr>
<td><code>409</code></td>
<td><code>CONFLICT</code></td>
</tr>
<tr>
<td><code>500</code></td>
<td><code>INTERNAL_SERVER_ERROR</code></td>
</tr>
</tbody>
</table>
<h2 id="53-清空数据库中所有表的内容"><a class="markdownIt-Anchor" href="#53-清空数据库中所有表的内容"></a> 5.3. 清空数据库中所有表的内容</h2>
<p>在开发和测试中,尤其是运行集成测试或重置开发环境时,可能需要快速清空数据库中的所有表数据。</p>
<p>我根据 MySQL 写了一个清空所有表数据的脚本,确保测试数据或旧数据被完全移除,便于后续的干净测试或初始化操作。</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><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> dataSource <span class="keyword">from</span> <span class="string">'../config/data-source'</span>;</span><br><span class="line"></span><br><span class="line"><span class="keyword">async</span> <span class="keyword">function</span> <span class="title function_">deleteAllData</span>(<span class="params"></span>) {</span><br><span class="line"> <span class="keyword">try</span> {</span><br><span class="line"> <span class="keyword">await</span> dataSource.<span class="title function_">initialize</span>();</span><br><span class="line"> <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">'已连接到数据库'</span>);</span><br><span class="line"></span><br><span class="line"> <span class="keyword">const</span> entityMetadatas = dataSource.<span class="property">entityMetadatas</span>;</span><br><span class="line"> <span class="keyword">const</span> totalTables = entityMetadatas.<span class="property">length</span>;</span><br><span class="line"></span><br><span class="line"> <span class="keyword">await</span> dataSource.<span class="title function_">query</span>(<span class="string">'BEGIN'</span>);</span><br><span class="line"> <span class="keyword">await</span> dataSource.<span class="title function_">query</span>(<span class="string">'SET FOREIGN_KEY_CHECKS = 0'</span>);</span><br><span class="line"></span><br><span class="line"> <span class="keyword">for</span> (<span class="keyword">let</span> i = <span class="number">0</span>; i < totalTables; i++) {</span><br><span class="line"> <span class="keyword">const</span> entityMetadata = entityMetadatas[i];</span><br><span class="line"> <span class="keyword">const</span> tableName = entityMetadata.<span class="property">tableName</span>;</span><br><span class="line"></span><br><span class="line"> <span class="keyword">const</span> startTime = <span class="title class_">Date</span>.<span class="title function_">now</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_">console</span>.<span class="title function_">log</span>(<span class="string">`清空表:<span class="subst">${tableName}</span>`</span>);</span><br><span class="line"></span><br><span class="line"> <span class="keyword">await</span> dataSource.<span class="title function_">query</span>(<span class="string">`TRUNCATE TABLE \`<span class="subst">${tableName}</span>\``</span>);</span><br><span class="line"></span><br><span class="line"> <span class="keyword">const</span> elapsedTime = (<span class="title class_">Date</span>.<span class="title function_">now</span>() - startTime) / <span class="number">1000</span>;</span><br><span class="line"> <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">`清空表 (<span class="subst">${i + <span class="number">1</span>}</span>/<span class="subst">${totalTables}</span>): <span class="subst">${tableName}</span> 完成,耗时: <span class="subst">${elapsedTime.toFixed(<span class="number">2</span>)}</span> 秒`</span>);</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 class="subst">${tableName}</span> 时报错:`</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">await</span> dataSource.<span class="title function_">query</span>(<span class="string">'COMMIT'</span>);</span><br><span class="line"> <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">'所有表都被清空'</span>);</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 class="keyword">await</span> dataSource.<span class="title function_">query</span>(<span class="string">'ROLLBACK'</span>);</span><br><span class="line"> } <span class="keyword">finally</span> {</span><br><span class="line"> <span class="keyword">await</span> dataSource.<span class="title function_">query</span>(<span class="string">'SET FOREIGN_KEY_CHECKS = 1'</span>);</span><br><span class="line"> <span class="keyword">await</span> dataSource.<span class="title function_">destroy</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_">deleteAllData</span>().<span class="title function_">catch</span>(<span class="function"><span class="params">error</span> =></span> {</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></pre></td></tr></tbody></table></figure>
<p>可以在 <code>package.json</code> 中添加以下命令来使用:</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="attr">"scripts"</span><span class="punctuation">:</span> <span class="punctuation">{</span></span><br><span class="line"> <span class="comment">// ...</span></span><br><span class="line"> <span class="attr">"delete-all-data"</span><span class="punctuation">:</span> <span class="string">"ts-node src/database/delete-all-data.ts"</span></span><br><span class="line"><span class="punctuation">}</span></span><br></pre></td></tr></tbody></table></figure>
</body></html></div></article></div></main><footer><div class="paginator"><a class="prev" href="17cc.html">上一篇</a><a class="next" href="5a4b.html">下一篇</a></div><!-- Webmention 显示区域--><div class="webmention-section webmention-empty" data-page-url="posts/8853.html" data-full-url="https://cytrogen.icu/posts/8853.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>