前言
我是前端,该项目是用来学习全栈的练手项目,所以我的开发不同于公司开发,主要是学习为主。从页面开始开发,先写一个页面,然后写这个页面后面所需要的所有功能,包括后端服务,数据库,缓存等。这样我们看到一个页面的时候,不仅仅是一个页面,而是能想到这个页面的每一个功能从前端到后端功能的具体实现。
首先,我们的系统是面向多用户的,也就是一个纯正的 C 端项目,任何人可以通过网站,注册一个账号,登录使用该系统.
所以我们就从注册页面开始开发,先完成注册页面和一个验证码发送功能,顺便也把相关的后端项目、MySQL数据库、redis缓存等从零开始搭建。
引入图标
图标是每个前端必不可少的,我们可以直接引入阿里的图标库,写成公共组件,然后在页面中引入使用。
在src/config.ts 中添加如下代码:
import { Icon } from 'antd'
....
export default Icon.createFromIconfont('//at.alicdn.com/t/c/font_4672973_oo70pxoae9j.js')
在src/page/Index/index.tsx 中引入图标:
import Icon from '@/config'
...
<Button theme="primary" onClick={()=> { }}>按钮</Button>
<br />
<Icon type="icon-youxiang" />
出现下面效果就是引入成功了。
注册页面
首先在 src/page 新建 Login 文件夹,在文件夹添加两个文件 index.tsx 和 index.less。我们先把注册页面的静态页面写出来,首先给 index.tsx 添加代码如下:
export default function Login() {
return <div className="auth">注册</div>;
}
为它添加一个路由配置,在 src/router/index.tsx 中添加如下代码:
import Login from '@/page/Login'
...
{
path: "/login",
component: Login,
}
重启服务,如下所示代表登录注册页面创建成功了:
接下来为 src/page/Login/index.tsx 添加静态页面代码:
// src/page/Login/index.tsx
import { List, Input, Button } from "zarm";
import Icon from "@/config";
import "./index.less";
export default function Login() {
return (
<div className="auth">
<div className="head" />
<div className="tab">
<span>注册</span>
</div>
<div className="form">
<List bordered={false}>
<List.Item prefix={<Icon type="icon-youxiang" />}>
<Input placeholder="请输入邮箱作为用户名" />
</List.Item>
<List.Item prefix={<Icon type="icon-yanzhengma" />}>
<Input placeholder="请输入验证码" />
<Button size="xs" className="get" theme="primary">
获取验证码
</Button>
</List.Item>
<List.Item prefix={<Icon type="icon-mima" />}>
<Input placeholder="请输入密码" type="password" />
</List.Item>
<List.Item prefix={<Icon type="icon-mima" />}>
<Input placeholder="请再次输入密码" type="password" />
</List.Item>
</List>
</div>
<div className="operation">
<Button block theme="primary">
注册
</Button>
</div>
</div>
);
}
增加样式文件 src/page/Login/index.less 如下:
.auth {
min-height: 100vh;
background-image: linear-gradient(
217deg,
#6fb9f8,
#3daaf85e,
#49d3fc1a,
#3fd3ff00
);
.head {
height: 200px;
background: url("//s.yezgea02.com/1616032174786/cryptocurrency.png")
no-repeat center;
background-size: 120%;
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
img {
width: 34px;
margin: 15px 0 0 15px;
}
}
.tab {
color: #597fe7;
padding: 30px 24px 10px 24px;
> span {
margin-right: 10px;
font-size: 14px;
font-weight: bold;
&.avtive {
font-size: 20px;
border-bottom: 2px solid #597fe7;
padding-bottom: 6px;
}
}
}
.form {
padding: 0 6px;
.za-list {
.za-list-item {
background-color: transparent;
&::after {
border-top: none;
}
}
}
}
.operation {
padding: 10px 24px 0 24px;
.agree {
display: flex;
align-items: center;
margin-bottom: 10px;
label {
margin-left: 10px;
font-size: 14px;
}
}
}
.get {
width: 160px;
}
}
PS:这里的样式也包括登录页面的样式,后面就不会写了,就用这个样式。
刷新浏览器页面,展示如下所示:
此时我们已经完成注册页面需要的内容。
给页面加上相应的逻辑,首先是账号、验证码、密码、确认密码:
...
const [username, setUsername] = useState<string>(""); // 账号
const [verify, setVerify] = useState<string>(""); // 验证码
const [password, setPassword] = useState<string>(""); // 密码
const [confirmPassword, setConfirmPassword] = useState<string>(""); // 确认密码
...
<Input
placeholder="请输入邮箱作为用户名"
value={username}
onChange={(e: { target: { value: SetStateAction<string> } }) =>
setUsername(e.target.value)
}
/>
...
<Input
ref={verifyRef}
placeholder="请输入验证码"
value={verify}
onChange={(e: { target: { value: SetStateAction<string> } }) =>
setVerify(e.target.value)
}
/>
...
<Input
placeholder="请输入密码"
type="password"
value={password}
onChange={(e: { target: { value: SetStateAction<string> } }) =>
setPassword(e.target.value)
}
/>
...
<Input
placeholder="请再次输入密码"
type="password"
value={confirmPassword}
onChange={(e: { target: { value: SetStateAction<string> } }) =>
setConfirmPassword(e.target.value)
}
/>
当输入框内容修改的时候,onChange 会被触发,接受的回调函数参数,便是变化的输入值,此时我们将其保存在声明的变量中。
PS: 这里我本来是不打算用
useState的,因为useState改变会导致页面组件重新渲染,而zarm官网Input组件的官方这样写,由于页面表单字段少,先这样吧。实现优先,后期看看怎么优化。
接下来就是获取验证码功能。
获取验证码功能
验证码(CAPTCHA)是一种用于区分计算机和人类用户的技术,它的主要功能包括:
- 防止自动化攻击:验证码可以有效防止自动化的机器人程序进行垃圾注册、恶意登录、刷票等非法操作,这些行为通常是由自动化脚本执行的。
- 提高安全性:在进行敏感操作,如账户登录、密码重置、金融交易等时,验证码可以作为一道额外的安全验证,确保操作是由账户的真正拥有者执行的。
- 保护网站资源:通过限制非正常用户的访问,验证码有助于减轻网站服务器的负担,保护网站免受恶意流量的冲击。
- 供用户体验:尽管验证码可能会给用户带来一定的不便,但它们在维护网络环境的公平性和安全性方面发挥着重要作用,是现代互联网安全的一个基本组成部分。
而验证码的种类繁多,包括传统的文本验证码、图片验证码、滑块验证码、语义验证码等,它们各自有着不同的特点和适用场景。
今天我们写的是文本验证码,也就是验证码是一串数字。由后端生成验证码,然后发送到用户邮箱中,成为用户注册信息的一部分。大致的步骤如下:
- 用户用邮箱地址作为用户名输入,然后点击获取验证码。
- 前端验证用户名是否为空,是否为邮箱格式。
- 后端验证用户名是否为空,是否为邮箱格式。
- 在数据库中查找是否已存在该用户名。
- 后端生成验证码。
- 将验证码发送到用户邮箱中。
- 将验证码存储到
redis中。 - 后端返回前端做出提示
详细泳道图:
前端验证
新建src/utils/verify.ts,写入验证邮箱的函数并导出:
// src/utils/verify.ts
export function validateEmail(email: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
引入验证函数并在“获取验证码”按钮上添加点击请求事件,验证用户名是否为邮箱格式:
// src/page/Login/index.tsx
....
import { List, Input, Button, Toast } from "zarm";
....
import { validateEmail } from "@/utils/verify";
....
const [confirmPassword, setConfirmPassword] = useState<string>("");
const handleGetCaptcha = async() => {
// 验证用户名是否为空
if (!username) {
Toast.show('请输入邮箱作为用户名')
return;
}
// 验证用户名是否为邮箱格式
if (!validateEmail(username)) {
Toast.show('请输入正确的邮箱,请重新输入')
return
}
};
....
<Button
size="xs"
className="get"
theme="primary"
onClick={() => handleGetCaptcha()}
>
获取验证码
</Button>
测试一下验证:
后端验证
前端接口请求
在src/utils/request.ts文件,写入接口函数,这里我们用get请求:
// src/utils/request.ts
/**下面写接口函数以便于统一管理**/
// 注册页面获取验证码接口
export async function getCaptcha(username: string) {
return await axiosInstance.get("/user/captcha", {
params: {
username,
},
});
}
在src/page/Login/index.tsx 中引入写入接口函数:
// src/page/Login/index.tsx
....
import { getCaptcha } from "@/utils"; // 引入get请求
....
const handleGetCaptcha = async() => {
// 验证邮箱格式
if (!username) {
Toast.show('请输入邮箱')
return;
}
if (!validateEmail(username)) {
Toast.show('请输入正确的邮箱,请重新输入')
return
}
const res = await getCaptcha(username);
console.log(res);
};
在用户名中输入一个正确格式邮箱,点击获取验证码,在调试窗口中看到请求:
如图所示,看到了请求。但是没有任何响应,这是因为我们没有后端项目。接下来就来生成后端项目。
生成后端项目
生成后台项目,然后运行。
nest new account-nest-backend
配置 VSCode 调试
在该项目中,我们使用VSCode中的launch.json来配置调试。在项目根目录下,创建一个.vscode文件夹,并在其中创建launch.json文件,写入以下配置:
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"name": "nest debug",
"request": "launch",
"runtimeExecutable": "npm",
"runtimeArgs": ["run-script", "start:dev"],
"console": "integratedTerminal",
"skipFiles": ["<node_internals>/**"],
"program": "${workspaceFolder}/src/main.ts",
"outFiles": ["${workspaceFolder}/dist/**/*.js"]
}
]
}
配置完成后,保存后在vscode中按F5键,出现如下所示:
右边出了一个新的调试,左边和你直接运行npm run start:dev 一样,但是它会自动附加调试。
并且在vscode的右上角出现了调试的控制台:
这里我遇到一个问题,那就是在
node版本中,v22版本不支持调试,v21版本可以。所以我用的是v21版本。具体原因待查
在src/app.controller.ts 中打一个断点,打开浏览器,输入localhost://3000,会发现,断点被触发了,左侧也查看相关的数据。
至此,我们就可以用vscode来调试nest后端项目了。
生成user模块
先在src/main.ts中写入 app.enableCors();用来去除跨域问题
// src/main.ts
....
const app = await NestFactory.create(AppModule);
app.enableCors();
然后在 nest-cli.json 里添加 generateOptions,设置 spec 为 false
// nest-cli.json
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"generateOptions":{
"spec": false
},
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
这样生成代码的时候不会生成测试代码
由于我们设计注册属于用户模块,我们在后端生成一个user的nest模块
nest g resource user
在src/user/user.controller.ts 中写入获取验证码的接口,代码如下:
// src/user/user.controller.ts
import { Controller, Get, Query } from '@nestjs/common';
import { UserService } from './user.service';
@Controller('user')
export class UserController {
constructor(private readonly userService: UserService) {}
@Get('captcha')
getCaptcha(@Query() params) {
console.log(params, '获取验证码');
}
}
再次点击获取验证码的接口,在调试窗口中看到请求:
看到请求成功,再看看后台项目是否有打印请求参数:
请求体校验
前端发给后端的数据,不仅仅是自己要检查一下,后端也需要检查。被检查的数据叫请求体(DTO文件传输对象),我们来做后端的请求体检查
首先生成一个验证码接口请求体的 dto。在src/user下创建目录dto,然后创建一个captcha.dto.ts文件,写入以下代码:
export class CaptchaDto {
username: string;
}
在src/user/user.controller.ts 中导入 CaptchaDto,并在 getCaptcha 方法中使用 @Query 装饰器来获取请求体:
// src/user/user.controller.ts
...
import { CaptchaDto } from './dto/captcha.dto';
...
@Get('captcha')
getCaptcha(@Query() params: CaptchaDto) {
console.log(params, '获取验证码');
return 'success'
}
然后加一下 ValidationPipe,来对请求体做校验。
npm i --save class-validator class-transformer
全局启用 ValidationPipe:
// src/main.ts
...
import { ValidationPipe } from '@nestjs/common';
...
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe());
然后在 src/user/dto/captcha.dto.ts 中写入校验规则和提示语,来对请求体做校验:
// src/user/dto/captcha.dto.ts
import { IsEmail, IsNotEmpty } from 'class-validator';
export class CaptchaDto {
@IsNotEmpty({ message: '用户名不能为空' })
@IsEmail(
{},
{
message: '用户名必须为邮箱格式,以方便接收验证码',
},
)
username: string;
}
然后在用户名随便输入不是邮箱地址的字符,前端的验证注释。 再次点击获取验证码的接口,在调试窗口中看到请求:
可以看到,请求体在后端验证,并且返回了错误信息。输入一个正确的邮箱地址,再次点击获取验证码
可以看到,请求体在后端通过验证,并且返回了成功信息。
接下来就是要查看该用户(邮箱地址)是否注册过了。也就是在数据库用户表中查询该用户是否存在。
先部署数据库,我们这里用的是MySQL。
数据库查询
创建数据库
打开Docker Desktop,下载一个mysql镜像(该操作需要翻墙)。
点击这里搜索:
输入mysql,下方出来后点击Pull:
正在下载:
下载完成后点击这里,创建一个容器。
输入参数:
这里端口 3306 就是 client 连接 mysql 的端口。
(另一个 33060 端口是 mysql8 新加的管理 mysql server 的端口,这里用不到)
指定 volume,用本地目录作为数据卷挂载到容器的 /var/lib/mysql 目录,这个是 mysql 保存数据的目录。
(这里的 D:\MYSQL 是我本地的一个目录,任意目录都行。)
MYSQL_ROOT_PASSWORD,也就是 client 连接 mysql server 的密码。
然后点击 Run 出现下面的这个就表示创建成功了:
MySQL Workbench 连接并新建数据库
打开MySQL Workbench,点击界面中的+号,选择Standard TCP/IP over SSH,然后填写以下信息:
输入密码
然后点击 Test Connection,测试连接是否成功。
连接成功后就点击 ok 出现该页面就表示成功了
PS: 这里用MySQL Workbench 连接而不用DBeaver的原因是,DBeaver 连接不上,貌似是和MySQL Workbench 版本有关。
创建一个新的database(或者叫schema)输入一个名称,然后点击 Apply 。
至此,我们可以在MySQL Workbench中查看、修改数据库了。
nest 连接数据库
nest中操作MySQL数据库,我们选择用mysql2和typeorm来操作MySQL。
安装这两个对应相关的包
npm install --save @nestjs/typeorm typeorm mysql2
在 AppModule 中引入 TypeOrmModule 模块,并配置数据库连接信息:
// src/app.module.ts
....
import { TypeOrmModule } from '@nestjs/typeorm';
@Module({
imports: [
TypeOrmModule.forRoot({
type: "mysql",
host: "localhost",
port: 3306,
username: "root",
password: "guang", // 这里的密码是你在创建容器的时候指定的密码
database: "account", // 这里的数据库名称是你在创建数据库的时候指定的名称
synchronize: true,
logging: true,
entities: [],
poolSize: 10,
connectorPackage: 'mysql2',
extra: {
authPlugin: 'sha256_password',
}
}),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
生成数据库用户表
接下来我们要创建一个简单的数据库用户表,我们这里用的是typeorm。
添加 src/user/entities 目录,新建 1 个实体 User。创建四个字段,id、username、createTime、updateTime(id、用户名、创建时间和更新时间)。
// src/user/entities/user.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn
} from "typeorm";
@Entity({
name: 'users'
})
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({
length: 50,
comment: '用户名'
})
username: string;
@CreateDateColumn()
createTime: Date;
@UpdateDateColumn()
updateTime: Date;
}
然后在 src/user/user.module.ts 中引入 User 实体:
点击F5,出现如下所示,就表示连接成功了。
去MySQL Workbench中查看数据库,就可以看到我们创建的表并生成字段了。
接下来就是数据库查询用户、生成验证码、发送验证码,把验证码写入Redis等操作.
查询用户
在src/user/user.controller.ts 中修改接口:
// src/user/user.controller.ts
...
import { User } from './entities/user.entity';
...
@Get('captcha')
async getCaptcha(@Query() params: CaptchaDto) {
return await this.userService.getCaptcha(params.username);
}
在src/user/user.service.ts 中引入typeorm和 User 实体并查询:
// src/user/user.service.ts
...
import { Injectable, BadRequestException } from '@nestjs/common';
import { InjectEntityManager } from '@nestjs/typeorm';
import { EntityManager } from 'typeorm';
import { User } from './entities/user.entity';
...
@Injectable()
export class UserService {
@InjectEntityManager()
entityManager: EntityManager;
async getCaptcha(username: string) {
const findUser = await this.entityManager.findOne(User, {
where: {
username
}
})
if (findUser) {
throw new BadRequestException('该用户已注册了')
}
}
}
生成验证码
// src/user/user.service.ts
...
if (findUser) {
throw new BadRequestException('该用户已注册了')
}
const captcha = Math.random().toString().slice(2,8) // 随机生成6位数字验证码
发送验证码邮件
封装模块
封装一个email模块
nest g resource email
安装发送邮件用的包:
npm i --save nodemailer
在 EmailService 里实现 sendMail 方法
// src/email/email.service.ts
import { Injectable } from '@nestjs/common';
import { createTransport, Transporter } from 'nodemailer';
@Injectable()
export class EmailService {
transporter: Transporter;
constructor() {
this.transporter = createTransport({
host: 'smtp.qq.com',
port: 587,
secure: false,
auth: {
user: '你的邮箱地址',
pass: '你的授权码',
},
});
}
async sendMail({ to, subject, html }) {
await this.transporter.sendMail({
from: {
name: '记账小助手',
address: '你的邮箱地址',
},
to,
subject,
html,
});
}
}
获取授权码
我在这里用的 qq 邮箱(主要是免费,练手用的),你也可以换成别的邮箱。或者你也可以买专门发邮件的服务,填写对应的 smtp 服务的域名和端口就好了。
现在我就用我的QQ邮箱为例。登录QQ邮箱,点击设置:
点击账号:
下划找到 POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV 服务,然后点击开启服务:
点击管理服务:
点击生成授权码:
这里会让你验证一下:
验证完就复制下面的授权码:
发送验证码邮件
把QQ邮箱和授权码填写在src/email/email.service.ts中,并在src/email/email.module.ts把 EmailModule 声明为全局的,并且导出 EmailService:
// src/email/email.module.ts
import { Global, Module } from '@nestjs/common';
import { EmailService } from './email.service';
import { EmailController } from './email.controller';
@Global() // 声明为全局的
@Module({
controllers: [EmailController],
providers: [EmailService],
exports: [EmailService], // 导出 EmailService
})
export class EmailModule {}
在src/user/user.service.ts中引入 EmailService,并在 getCaptcha 方法中调用 sendMail 方法发送邮件:
// src/user/user.service.ts
import { Injectable, BadRequestException, Inject } from '@nestjs/common';
...
import { EmailService } from 'src/email/email.service';
...
@InjectEntityManager()
entityManager: EntityManager;
@Inject(EmailService)
emailService: EmailService;
....
const captcha = Math.random().toString().slice(2, 8);
try {
await this.emailService.sendMail({
to: username,
subject: '注册验证码',
html: `<p>你的注册验证码是 ${captcha}</p>`,
});
} catch (error) {
console.log(error);
throw new BadRequestException('验证码获取失败');
}
由于用了 async/await,无法监听到错误,所以用了try...catch...;
在生成验证码写一个console.log把验证码打印出来,然后在前端写一个你自己的邮箱,点击获取验证码,测试一下是否能发送邮件:
看到生成的是788857,然后去邮箱中查看:
邮箱中也是788857,说明发送成功。
接下来是把验证码写入Redis。
验证码写入 Redis
封装模块和安装包
我们需要先封装个 redis 模块。
nest g module redis
nest g service redis
安装 redis 的包:
npm i --save redis
配置本地 Redis 服务
打开 Docker Desktop,下载一个 redis 镜像(注意下载需要翻墙)。
下载完成后,点击运行:
填写一些容器信息
端口映射就是把主机的 6379 端口映射到容器内的 6379 端口,这样就能直接通过本机端口访问容器内的服务了。
指定数据卷,用本机的任意一个目录挂载到容器内的 /data 目录,这样数据就会保存在本机。
跑起来之后是这样的:
nest 连接 Redis 并生成公共模块
添加连接 redis 的 provider 到 src/redis/redis.module.ts
// src/redis/redis.module.ts
import { Global, Module } from '@nestjs/common';
import { RedisService } from './redis.service';
import { createClient } from 'redis';
@Global()
@Module({
providers: [
RedisService,
{
provide: 'REDIS_CLIENT',
async useFactory() {
const client = createClient({
socket: {
host: 'localhost',
port: 6379
},
database: 1
});
await client.connect();
return client;
}
}
],
exports: [RedisService] // 导出 RedisService以便调用
})
export class RedisModule {}
这里用 @Global() 把它声明为全局模块,这样只需要在 AppModule 里引入,别的模块不用引入也可以注入 RedisService 了。
然后写下 RedisService
// src/redis/redis.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { RedisClientType } from 'redis';
@Injectable()
export class RedisService {
@Inject('REDIS_CLIENT')
private redisClient: RedisClientType;
async get(key: string) {
return await this.redisClient.get(key);
}
async set(key: string, value: string | number, ttl?: number) {
await this.redisClient.set(key, value);
if (ttl) {
await this.redisClient.expire(key, ttl);
}
}
}
注入 redisClient,实现 get、set 方法,set 方法支持指定过期时间。
在 Redis 中写入验证码
在 src/user/user.service.ts 中引入 RedisService,用户名加前缀register_captcha_做key,验证码做值在 getCaptcha 方法中调用 set 方法写入:
// src/user/user.service.ts
...
import { EmailService } from'src/email/email.service';
import { RedisService } from'src/redis/redis.service';
...
@Inject(EmailService)
emailService: EmailService;
@Inject(RedisService)
redisService: RedisService;
....
await this.emailService.sendMail({
to: username,
subject: '注册验证码',
html: `<p>你的注册验证码是 ${captcha}</p>`,
});
await this.redisService.set(`register_captcha_${username}`, captcha, 5 * 60);
return '验证码获取成功';
现在再测试一下,点击前端获取验证码:
后端看到验证码是810184,然后去邮箱里中查看:
同上,再去Redis中查看,打开Redis Insight:
三者一致,说明写入成功。
接下来就是前端提示了
前端提示
后端返回前端的数据如下:
只需要在前端src/page/Login/index.tsx做一个简单的判断即可:
// src/page/Login/index.tsx
...
const res = await getCaptcha(username);
if(res.status === 200 || res.status === 201) {
Toast.show(res.data)
} else {
Toast.show(res.message)
}
至此,我们的发送验证码功能就完成了。
总结
这节我们引入了Icont图标、完成了注册页面的开发。
从获取验证码功能开发的过程中,我们实现了:
- 前端验证用户名。
- nestjs后端服务生成、调试配置、用户模块生成和引入ValidationPipe做请求体验证。
- 用 Docker Desktop 生成MySQL数据服务、MySQL Workbench连接新建数据库。
- 创建了 User 的 entity,通过typeorm的自动建表功能,在数据库创建了对应的用户表。
- 后台引入 nodemailer 来发邮件,如果是线上可以其他的平台的邮件推送服务。
- 用 Docker Desktop 生成 redis 服务并写入验证码
到了现在,我们已经把该项目所有需要的服务都搭建好了,接下来就是各个功能的开发了。