~cytrogen/blog-public

ref: 88eebf3dfdd8ab819fa1a84e1976a8a75d5af2b6 blog-public/posts/8853.html -rw-r--r-- 289.0 KiB
88eebf3dCytrogen Deploy 2026-02-19 08:34:27 3 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
<!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(() =&gt; Orders, order =&gt; 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>) =&gt; {</span><br><span class="line">  <span class="keyword">return</span> (</span><br><span class="line">    <span class="language-xml"><span class="tag">&lt;<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>&gt;</span></span></span><br><span class="line"><span class="language-xml">      <span class="tag">&lt;<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>&gt;</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">&lt;/<span class="name">div</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">    <span class="tag">&lt;/<span class="name">div</span>&gt;</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>) =&gt; {</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>) =&gt;</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">() =&gt;</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>) =&gt; {</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>) =&gt; {</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">&lt;<span class="name">AuthLayout</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">      <span class="tag">&lt;<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>&gt;</span></span></span><br><span class="line"><span class="language-xml">        注册账户</span></span><br><span class="line"><span class="language-xml">      <span class="tag">&lt;/<span class="name">h2</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">      <span class="tag">&lt;<span class="name">form</span> <span class="attr">onSubmit</span>=<span class="string">{formik.handleSubmit}</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">        <span class="tag">&lt;<span class="name">div</span> <span class="attr">className</span>=<span class="string">"mb-4"</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">          <span class="tag">&lt;<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>&gt;</span></span></span><br><span class="line"><span class="language-xml">            用户名</span></span><br><span class="line"><span class="language-xml">          <span class="tag">&lt;/<span class="name">label</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">          <span class="tag">&lt;<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> &amp;&amp; <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">          /&gt;</span></span></span><br><span class="line"><span class="language-xml">          {formik.touched.username &amp;&amp; formik.errors.username &amp;&amp; (</span></span><br><span class="line"><span class="language-xml">            <span class="tag">&lt;<span class="name">p</span> <span class="attr">className</span>=<span class="string">"text-sm text-error mt-1"</span>&gt;</span>{formik.errors.username}<span class="tag">&lt;/<span class="name">p</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">          )}</span></span><br><span class="line"><span class="language-xml">        <span class="tag">&lt;/<span class="name">div</span>&gt;</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">&lt;/<span class="name">form</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">    <span class="tag">&lt;/<span class="name">AuthLayout</span>&gt;</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>) =&gt; {</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">&lt;<span class="name">AuthLayout</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">      <span class="tag">&lt;<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>&gt;</span></span></span><br><span class="line"><span class="language-xml">        注册账户</span></span><br><span class="line"><span class="language-xml">      <span class="tag">&lt;/<span class="name">h2</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">      <span class="tag">&lt;<span class="name">form</span> <span class="attr">onSubmit</span>=<span class="string">{formik.handleSubmit}</span>&gt;</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">&lt;<span class="name">div</span> <span class="attr">className</span>=<span class="string">"mb-4"</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">          <span class="tag">&lt;<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>&gt;</span></span></span><br><span class="line"><span class="language-xml">            邮箱地址</span></span><br><span class="line"><span class="language-xml">          <span class="tag">&lt;/<span class="name">label</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">          <span class="tag">&lt;<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> &amp;&amp; <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">          /&gt;</span></span></span><br><span class="line"><span class="language-xml">          {formik.touched.email &amp;&amp; formik.errors.email &amp;&amp; (</span></span><br><span class="line"><span class="language-xml">            <span class="tag">&lt;<span class="name">p</span> <span class="attr">className</span>=<span class="string">"text-sm text-error mt-1"</span>&gt;</span>{formik.errors.email}<span class="tag">&lt;/<span class="name">p</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">          )}</span></span><br><span class="line"><span class="language-xml">        <span class="tag">&lt;/<span class="name">div</span>&gt;</span></span></span><br><span class="line"><span class="language-xml"></span></span><br><span class="line"><span class="language-xml">        <span class="tag">&lt;<span class="name">div</span> <span class="attr">className</span>=<span class="string">"mb-4"</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">          <span class="tag">&lt;<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>&gt;</span></span></span><br><span class="line"><span class="language-xml">            密码</span></span><br><span class="line"><span class="language-xml">          <span class="tag">&lt;/<span class="name">label</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">          <span class="tag">&lt;<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> &amp;&amp; <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">          /&gt;</span></span></span><br><span class="line"><span class="language-xml">          {formik.touched.password &amp;&amp; formik.errors.password &amp;&amp; (</span></span><br><span class="line"><span class="language-xml">            <span class="tag">&lt;<span class="name">p</span> <span class="attr">className</span>=<span class="string">"text-sm text-error mt-1"</span>&gt;</span>{formik.errors.password}<span class="tag">&lt;/<span class="name">p</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">          )}</span></span><br><span class="line"><span class="language-xml">        <span class="tag">&lt;/<span class="name">div</span>&gt;</span></span></span><br><span class="line"><span class="language-xml"></span></span><br><span class="line"><span class="language-xml">        <span class="tag">&lt;<span class="name">div</span> <span class="attr">className</span>=<span class="string">"mb-4"</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">          <span class="tag">&lt;<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>&gt;</span></span></span><br><span class="line"><span class="language-xml">            确认密码</span></span><br><span class="line"><span class="language-xml">          <span class="tag">&lt;/<span class="name">label</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">          <span class="tag">&lt;<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> &amp;&amp; <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">          /&gt;</span></span></span><br><span class="line"><span class="language-xml">          {formik.touched.confirmPassword &amp;&amp; formik.errors.confirmPassword &amp;&amp; (</span></span><br><span class="line"><span class="language-xml">            <span class="tag">&lt;<span class="name">p</span> <span class="attr">className</span>=<span class="string">"text-sm text-error mt-1"</span>&gt;</span>{formik.errors.confirmPassword}<span class="tag">&lt;/<span class="name">p</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">          )}</span></span><br><span class="line"><span class="language-xml">        <span class="tag">&lt;/<span class="name">div</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">      <span class="tag">&lt;/<span class="name">form</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">    <span class="tag">&lt;/<span class="name">AuthLayout</span>&gt;</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>) =&gt; {</span><br><span class="line">    <span class="keyword">const</span> [isSubmitting, setIsSubmitting] = useState&lt;<span class="built_in">boolean</span>&gt;(<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>&lt;button&gt;</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>) =&gt; {</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">    &lt;<span class="title class_">AuthLayout</span>&gt;</span><br><span class="line">      <span class="language-xml"><span class="tag">&lt;<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>&gt;</span></span></span><br><span class="line"><span class="language-xml">        注册账户</span></span><br><span class="line"><span class="language-xml">      <span class="tag">&lt;/<span class="name">h2</span>&gt;</span></span></span><br><span class="line">      <span class="language-xml"><span class="tag">&lt;<span class="name">form</span> <span class="attr">onSubmit</span>=<span class="string">{formik.handleSubmit}</span>&gt;</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">&lt;<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>&gt;</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">&lt;/<span class="name">button</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">      <span class="tag">&lt;/<span class="name">form</span>&gt;</span></span></span><br><span class="line">  );</span><br><span class="line">};</span><br><span class="line">&lt;/button&gt;</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">&lt;<span class="name">Register</span> /&gt;</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>&lt;<span class="title class_">Users</span>&gt;): <span class="title class_">Promise</span>&lt;<span class="title class_">Users</span>&gt; {</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>&lt;<span class="title class_">Users</span>&gt; {</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>&lt;<span class="title class_">Users</span>&gt;): <span class="title class_">Promise</span>&lt;<span class="title class_">Users</span>&gt; {</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>&lt;<span class="built_in">void</span>&gt; {</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>&lt;<span class="title class_">Users</span>&gt;</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>&lt;<span class="title class_">Users</span>&gt;): <span class="title class_">Promise</span>&lt;<span class="title class_">Users</span>&gt; {</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>&lt;<span class="title class_">Users</span>&gt;): <span class="title class_">Promise</span>&lt;<span class="title class_">Users</span>&gt; {</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>&lt;<span class="built_in">void</span>&gt; {</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>&lt;<span class="title class_">Users</span>&gt; {</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>&lt;<span class="title class_">Users</span>&gt; {  <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>&lt;<span class="title class_">Users</span>&gt;</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>&lt;<span class="title class_">Users</span>&gt; {</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&lt;<span class="built_in">boolean</span>&gt;(<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> =&gt;</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> =&gt;</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> =&gt;</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>) =&gt;</span> <span class="title class_">Promise</span>&lt;<span class="title class_">User</span>&gt;;</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>) =&gt; { </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>&lt;<span class="title class_">User</span>&gt; = <span class="keyword">await</span> api.<span class="property">post</span>&lt;<span class="title class_">User</span>&gt;(<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">() =&gt;</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) =&gt; {</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">&lt;<span class="name">AuthLayout</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">      <span class="tag">&lt;<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> /&gt;</span></span></span><br><span class="line"><span class="language-xml">      <span class="tag">&lt;<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>&gt;</span></span></span><br><span class="line"><span class="language-xml">        注册账户</span></span><br><span class="line"><span class="language-xml">      <span class="tag">&lt;/<span class="name">h2</span>&gt;</span></span></span><br><span class="line"><span class="language-xml"></span></span><br><span class="line"><span class="language-xml">      {error &amp;&amp; (</span></span><br><span class="line"><span class="language-xml">        <span class="tag">&lt;<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>&gt;</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">&lt;/<span class="name">div</span>&gt;</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">&lt;/<span class="name">AuthLayout</span>&gt;</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>&lt;input&gt;</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>&lt;button&gt;</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">&lt;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">&gt;</span><br><span class="line">  {isLoading ? <span class="string">"注册中…"</span> : <span class="string">"注册"</span>}</span><br><span class="line">&lt;/button&gt;</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">&lt;<span class="name">VerifyEmail</span> /&gt;</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>) =&gt;</span> <span class="title class_">Promise</span>&lt;<span class="title class_">VerificationResponse</span>&gt;;</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>) =&gt;</span> <span class="title class_">Promise</span>&lt;<span class="title class_">VerificationResponse</span>&gt;;</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>) =&gt; { </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> =&gt;</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> =&gt;</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> =&gt;</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> =&gt;</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> =&gt;</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> =&gt;</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> =&gt;</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> =&gt;</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">&lt;<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>&gt;</span> </span></span><br><span class="line"><span class="language-xml">      <span class="tag">&lt;<span class="name">div</span> <span class="attr">className</span>=<span class="string">"card w-96 bg-base-100 shadow-xl"</span>&gt;</span> </span></span><br><span class="line"><span class="language-xml">        <span class="tag">&lt;<span class="name">div</span> <span class="attr">className</span>=<span class="string">"card-body items-center text-center"</span>&gt;</span> </span></span><br><span class="line"><span class="language-xml">          <span class="tag">&lt;<span class="name">h2</span> <span class="attr">className</span>=<span class="string">"card-title mb-4"</span>&gt;</span>验证电子邮件<span class="tag">&lt;/<span class="name">h2</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">          {/* 不同状态的渲染将在这里实现 */}</span></span><br><span class="line"><span class="language-xml">        <span class="tag">&lt;/<span class="name">div</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">      <span class="tag">&lt;/<span class="name">div</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">    <span class="tag">&lt;/<span class="name">div</span>&gt;</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>) =&gt; { </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>) =&gt; { </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">() =&gt;</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 &amp;&amp; !verificationInProgress &amp;&amp; !emailVerified &amp;&amp; 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> =&gt;</span> { </span><br><span class="line">      <span class="keyword">if</span> (r.<span class="property">success</span> &amp;&amp; <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">() =&gt;</span> { </span><br><span class="line">  <span class="keyword">if</span> (emailVerified &amp;&amp; !isLoading) { </span><br><span class="line">    <span class="keyword">const</span> redirectTimer = <span class="built_in">setTimeout</span>(<span class="function">() =&gt;</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">() =&gt;</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">&lt;<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>&gt;</span> </span></span><br><span class="line"><span class="language-xml">    <span class="tag">&lt;<span class="name">div</span> <span class="attr">className</span>=<span class="string">"card w-96 bg-base-100 shadow-xl"</span>&gt;</span> </span></span><br><span class="line"><span class="language-xml">      <span class="tag">&lt;<span class="name">div</span> <span class="attr">className</span>=<span class="string">"card-body items-center text-center"</span>&gt;</span> </span></span><br><span class="line"><span class="language-xml">        <span class="tag">&lt;<span class="name">h2</span> <span class="attr">className</span>=<span class="string">"card-title mb-4"</span>&gt;</span>验证电子邮件<span class="tag">&lt;/<span class="name">h2</span>&gt;</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 &amp;&amp; ( </span></span><br><span class="line"><span class="language-xml">          <span class="tag">&lt;<span class="name">div</span> <span class="attr">className</span>=<span class="string">"flex flex-col items-center gap-4"</span>&gt;</span> </span></span><br><span class="line"><span class="language-xml">            <span class="tag">&lt;<span class="name">span</span> <span class="attr">className</span>=<span class="string">"loading loading-spinner loading-lg"</span> /&gt;</span> </span></span><br><span class="line"><span class="language-xml">            <span class="tag">&lt;<span class="name">p</span>&gt;</span>正在验证您的电子邮件...<span class="tag">&lt;/<span class="name">p</span>&gt;</span> </span></span><br><span class="line"><span class="language-xml">          <span class="tag">&lt;/<span class="name">div</span>&gt;</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 &amp;&amp; verificationError === EmailVerificationError.TOKEN_EXPIRED &amp;&amp; ( </span></span><br><span class="line"><span class="language-xml">          <span class="tag">&lt;<span class="name">div</span> <span class="attr">className</span>=<span class="string">"flex flex-col gap-4"</span>&gt;</span> </span></span><br><span class="line"><span class="language-xml">            <span class="tag">&lt;<span class="name">div</span> <span class="attr">className</span>=<span class="string">"alert alert-warning"</span>&gt;</span> </span></span><br><span class="line"><span class="language-xml">              <span class="tag">&lt;<span class="name">svg</span>&gt;</span>...<span class="tag">&lt;/<span class="name">svg</span>&gt;</span> </span></span><br><span class="line"><span class="language-xml">              <span class="tag">&lt;<span class="name">span</span>&gt;</span>{error}<span class="tag">&lt;/<span class="name">span</span>&gt;</span> </span></span><br><span class="line"><span class="language-xml">            <span class="tag">&lt;/<span class="name">div</span>&gt;</span> </span></span><br><span class="line"><span class="language-xml">            <span class="tag">&lt;<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">            &gt;</span> </span></span><br><span class="line"><span class="language-xml">              重新发送验证邮件 </span></span><br><span class="line"><span class="language-xml">            <span class="tag">&lt;/<span class="name">button</span>&gt;</span> </span></span><br><span class="line"><span class="language-xml">          <span class="tag">&lt;/<span class="name">div</span>&gt;</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 &amp;&amp; emailVerified &amp;&amp; ( </span></span><br><span class="line"><span class="language-xml">          <span class="tag">&lt;<span class="name">div</span> <span class="attr">className</span>=<span class="string">"alert alert-success"</span>&gt;</span> </span></span><br><span class="line"><span class="language-xml">            <span class="tag">&lt;<span class="name">svg</span>&gt;</span>...<span class="tag">&lt;/<span class="name">svg</span>&gt;</span> </span></span><br><span class="line"><span class="language-xml">            <span class="tag">&lt;<span class="name">span</span>&gt;</span>邮箱验证成功!即将跳转到登录页面...<span class="tag">&lt;/<span class="name">span</span>&gt;</span> </span></span><br><span class="line"><span class="language-xml">          <span class="tag">&lt;/<span class="name">div</span>&gt;</span> </span></span><br><span class="line"><span class="language-xml">        )}</span></span><br><span class="line"><span class="language-xml">      <span class="tag">&lt;/<span class="name">div</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">    <span class="tag">&lt;/<span class="name">div</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">  <span class="tag">&lt;/<span class="name">div</span>&gt;</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>&lt;<span class="title class_">UserState</span>&gt; = <span class="function">(<span class="params">set</span>) =&gt;</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>&lt;<span class="title class_">VerificationResponse</span>&gt; =&gt; {</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 &amp;&amp; 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>) =&gt; { </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>&lt;<span class="title class_">VerificationResponse</span>&gt;(<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>&lt;<span class="title class_">Users</span>&gt;,</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>&lt;{ <span class="attr">id</span>: <span class="built_in">string</span>; <span class="attr">email</span>: <span class="built_in">string</span> }&gt; {</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>&lt;<span class="title class_">VerificationResponse</span>&gt; {</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> &lt; <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>&lt;<span class="title class_">VerificationResponse</span>&gt; {</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>&lt;{ <span class="attr">message</span>: <span class="built_in">string</span> }&gt; {</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>&lt;{ <span class="attr">message</span>: <span class="built_in">string</span> }&gt; {</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>) =&gt; {</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>&lt;<span class="built_in">string</span>&gt;(<span class="string">'SMTP_HOST'</span>),</span><br><span class="line">          <span class="attr">port</span>: configService.<span class="property">getOrThrow</span>&lt;<span class="built_in">number</span>&gt;(<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>&lt;<span class="built_in">string</span>&gt;(<span class="string">'SMTP_USER'</span>),</span><br><span class="line">            <span class="attr">pass</span>: configService.<span class="property">getOrThrow</span>&lt;<span class="built_in">string</span>&gt;(<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" &lt;<span class="subst">${configService.getOrThrow&lt;<span class="built_in">string</span>&gt;(<span class="string">'SMTP_FROM_ADDRESS'</span>)}</span>&gt;`</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">&lt;<span class="name">header</span> <span class="attr">class</span>=<span class="string">"email-header"</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">  <span class="tag">&lt;<span class="name">div</span> <span class="attr">class</span>=<span class="string">"logo"</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">    <span class="tag">&lt;<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>&gt;</span></span></span><br><span class="line"><span class="language-xml">  <span class="tag">&lt;/<span class="name">div</span>&gt;</span></span></span><br><span class="line"><span class="language-xml"><span class="tag">&lt;/<span class="name">header</span>&gt;</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>&lt;<span class="built_in">string</span>&gt; {</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>&lt;<span class="built_in">string</span>&gt;(<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">&lt;!DOCTYPE <span class="keyword">html</span>&gt;</span></span></span><br><span class="line"><span class="language-xml"><span class="tag">&lt;<span class="name">html</span> <span class="attr">lang</span>=<span class="string">"zh"</span>&gt;</span></span></span><br><span class="line"><span class="language-xml"><span class="tag">&lt;<span class="name">head</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">  <span class="tag">&lt;<span class="name">meta</span> <span class="attr">charset</span>=<span class="string">"utf-8"</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">  <span class="tag">&lt;<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>&gt;</span></span></span><br><span class="line"><span class="language-xml">  <span class="tag">&lt;<span class="name">title</span>&gt;</span>验证你的邮箱地址<span class="tag">&lt;/<span class="name">title</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">  <span class="tag">&lt;<span class="name">style</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">      </span><span class="template-variable">{{&gt; <span class="name">styles</span>}}</span><span class="language-xml"></span></span><br><span class="line"><span class="language-xml">  <span class="tag">&lt;/<span class="name">style</span>&gt;</span></span></span><br><span class="line"><span class="language-xml"><span class="tag">&lt;/<span class="name">head</span>&gt;</span></span></span><br><span class="line"><span class="language-xml"><span class="tag">&lt;<span class="name">body</span>&gt;</span></span></span><br><span class="line"><span class="language-xml"></span><span class="template-variable">{{&gt; <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">&lt;<span class="name">div</span> <span class="attr">class</span>=<span class="string">"email-content"</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">  <span class="tag">&lt;<span class="name">h2</span>&gt;</span>您好,</span><span class="template-variable">{{<span class="name">username</span>}}</span><span class="language-xml"><span class="tag">&lt;/<span class="name">h2</span>&gt;</span></span></span><br><span class="line"><span class="language-xml"></span></span><br><span class="line"><span class="language-xml">  <span class="tag">&lt;<span class="name">p</span>&gt;</span>感谢您注册我们的服务。请点击下面的按钮验证您的邮箱地址:<span class="tag">&lt;/<span class="name">p</span>&gt;</span></span></span><br><span class="line"><span class="language-xml"></span></span><br><span class="line"><span class="language-xml">  <span class="tag">&lt;<span class="name">div</span> <span class="attr">class</span>=<span class="string">"button-container"</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">    <span class="tag">&lt;<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>&gt;</span>验证邮箱<span class="tag">&lt;/<span class="name">a</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">  <span class="tag">&lt;/<span class="name">div</span>&gt;</span></span></span><br><span class="line"><span class="language-xml"></span></span><br><span class="line"><span class="language-xml">  <span class="tag">&lt;<span class="name">p</span>&gt;</span>如果上面的按钮无法点击,请复制以下链接到浏览器地址栏:<span class="tag">&lt;/<span class="name">p</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">  <span class="tag">&lt;<span class="name">p</span> <span class="attr">class</span>=<span class="string">"verification-url"</span>&gt;</span></span><span class="template-variable">{{<span class="name">verificationUrl</span>}}</span><span class="language-xml"><span class="tag">&lt;/<span class="name">p</span>&gt;</span></span></span><br><span class="line"><span class="language-xml"></span></span><br><span class="line"><span class="language-xml">  <span class="tag">&lt;<span class="name">p</span>&gt;</span>此验证链接将在</span><span class="template-variable">{{<span class="name">expiresIn</span>}}</span><span class="language-xml">后过期。<span class="tag">&lt;/<span class="name">p</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">  <span class="tag">&lt;<span class="name">p</span>&gt;</span>如果您没有注册我们的服务,请忽略此邮件。<span class="tag">&lt;/<span class="name">p</span>&gt;</span></span></span><br><span class="line"><span class="language-xml"><span class="tag">&lt;/<span class="name">div</span>&gt;</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">{{&gt; <span class="name">footer</span>}}</span><span class="language-xml"></span></span><br><span class="line"><span class="language-xml"><span class="tag">&lt;/<span class="name">body</span>&gt;</span></span></span><br><span class="line"><span class="language-xml"><span class="tag">&lt;/<span class="name">html</span>&gt;</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">&lt;<span class="name">header</span> <span class="attr">class</span>=<span class="string">"email-header"</span>&gt;</span></span></span><br><span class="line"><span class="language-xml"><span class="tag">&lt;<span class="name">div</span> <span class="attr">class</span>=<span class="string">"logo"</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">  <span class="tag">&lt;<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>&gt;</span></span></span><br><span class="line"><span class="language-xml"><span class="tag">&lt;/<span class="name">div</span>&gt;</span></span></span><br><span class="line"><span class="language-xml"><span class="tag">&lt;/<span class="name">header</span>&gt;</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">&lt;<span class="name">footer</span> <span class="attr">class</span>=<span class="string">"email-footer"</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">  <span class="tag">&lt;<span class="name">p</span>&gt;</span>© </span><span class="template-variable">{{<span class="name">year</span>}}</span><span class="language-xml"> 你的公司名称. 保留所有权利。<span class="tag">&lt;/<span class="name">p</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">  <span class="tag">&lt;<span class="name">p</span>&gt;</span>此邮件由系统自动发送,请勿回复。<span class="tag">&lt;/<span class="name">p</span>&gt;</span></span></span><br><span class="line"><span class="language-xml"><span class="tag">&lt;/<span class="name">footer</span>&gt;</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 &lt; 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> =&gt;</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>