S08-08 Node-项目:mr-coderhub
[TOC]
项目:mr-coderhub
技术栈:
Node 16
koa 2
项目介绍
coderhub 旨在创建一个程序员分享生活动态的平台
完整的项目接口包括:
- 面向用户的业务接口
- 面向后台的管理接口
项目搭建
初始化项目
pnpm init项目目录
- 按功能模块划分
- 按业务模块划分
项目脚本

运行项目
pnpm start安装 koa2
pnpm add koa @koa/router koa-bodyparser创建、启动项目
1、基本架构

2、添加 router

封装路由
1、封装路由userRouter

2、导入路由

封装 app@
1、封装 app

2、导入 app

配置写入环境变量@
1、配置常量到./src/config/目录下


2、优化:将环境变量放入.env文件中

3、加载.env中的配置
依赖包:dotenv

用户注册

postman 创建全局变量@
用户注册路由

封装 UserController
1、抽取路由逻辑到UserController中

2、导入UserController实例,在 router 中做映射

封装 UserService
1、将数据库操作,抽取到UserService中

2、导入UserService实例

创建数据库

数据库连接
依赖包:mysql2
1、使用mysql2连接数据库

2、抽取数据库连接

3、导入数据库连接

4、判断数据库是否连接成功

创建用户表

存入数据库
1、使用预处理语句插入 user 数据

2、异步处理

3、controller 中也改成异步

4、创建用户成功

验证用户
1、验证用户名、密码是否为空

2、判断 name 是否已经存在
UserService

UserController

3、优化: 抽取验证用户的逻辑
抽取中间件

导入中间件

错误处理@
1、执行一次错误处理代码

2、在出错的位置发射错误事件

3、监听处理错误

4、优化: 抽取错误常量

使用常量


密码加密@
1、在路由中添加加密中间件

2、封装加密中间件

3、封装 md5 加密的工具函数

用户登录
登录凭证
为什么需要登录凭证
web 开发中使用最多的是 HTTP 协议,但是它是一个无状态的协议
无状态协议:HTTP 的每次请求都是一个单独的请求,和之前的请求没有关系。
用户登录
1、创建login.router.js路由文件

2、挂载loginRouter

3、封装 LoginController 并导入 login
const KoaRouter = require('@koa/router')
const { login } = require('../controller/login.controller')
const { verifyLogin } = require('../middleware/login.middleware')
const loginRouter = new KoaRouter({ prefix: '/login' }) + loginRouter.post('/', verifyLogin, login)
module.exports = loginRouter验证用户
0、使用中间件

1、验证用户中间件
判断用户名、密码是否为空
判断用户是否存在
判断密码是否正确
const { USERNAME_OR_PASSWORD_REQUIRED, USER_NOT_EXISTS, PASSWORD_IS_NOT_RIGHT } = require('../const/error.const')
const { findUserByUserame, checkPassword } = require('../service/user.service')
/* 验证用户登录 */
const verifyLogin = async (ctx, next) => {
const { username, password } = ctx.request.body
// 判断用户名、密码是否为空
if (!username || !password) {
return ctx.app.emit('error', USERNAME_OR_PASSWORD_REQUIRED, ctx)
}
// 判断用户是否存在
const users = await findUserByUserame(username)
const user = users[0]
if (!users.length) {
return ctx.app.emit('error', USER_NOT_EXISTS, ctx)
}
// 判断密码是否正确
if (user.password !== encryptMd5(password)) {
return ctx.app.emit('error', PASSWORD_IS_NOT_RIGHT, ctx)
}
// 保存user信息到ctx中
ctx.user = user
await next()
}
module.exports = {
verifyLogin
}2、添加错误常量
const USERNAME_OR_PASSWORD_REQUIRED = 'username_or_password_required'
const USER_ALREADY_EXISTS = 'user_already_exists'
+ const USER_NOT_EXISTS = 'user_not_exists'
+ const PASSWORD_IS_NOT_RIGHT = 'password_is_not_right'
module.exports = {
USERNAME_OR_PASSWORD_REQUIRED,
USER_ALREADY_EXISTS,
USER_NOT_EXISTS,
PASSWORD_IS_NOT_RIGHT
}3、添加错误处理
app.on('error', (err, ctx) => {
let code = 0
let message = ''
switch (err) {
+ case USERNAME_OR_PASSWORD_REQUIRED:
code = -1001
message = '用户名或密码不能为空~'
break
case USER_ALREADY_EXISTS:
code = -1002
message = '用户已经存在~'
break
+ case USER_NOT_EXISTS:
code = -1003
message = '用户不存在~'
break
+ case PASSWORD_IS_NOT_RIGHT:
code = -1004
message = '用户密码错误~'
break
}4、使用 UserService
/* 验证用户名是否存在 */
async findUserByUserame(username) {
const sql = 'SELECT * FROM `user` WHERE `username`=?;'
const [users] = await connection.execute(sql, [username])
return users
}颁发 token@
1、依赖包:jsonwebtoken
pnpm add jsonwebtoken2、创建 keys
# 开启openssl
openssl
# 创建私钥
genrsa -out private.key 2048
# 根据私钥创建公钥
rsa -in private.key -pubout -out public.key3、读取并导出私钥和公钥

注意: 这里的路径./keys会有问题,因为readFileSync()是从启动目录nodemon ./src/xx的位置开始计算的。
注意: 当algorithm: 'RS256' 时,要求生成的私钥长度最少为 2048
要写成如下路径:

或者写成这样:

4、使用私钥和公钥颁发 token

5、接口


验证 token@
1、创建/test路由

2、封装 test

3、验证未通过,报错


4、接口

5、封装: 验证 token 的中间件verifyAuth



postman 定义 token 变量@
1、在用户登录接口定义 token 全局变量

2、使用 token

自动注册路由
动态
发表动态
1、创建动态表moment

2、创建moment.router.js动态路由文件,使用create

3、创建moment.controller.js文件,处理路由逻辑

4、创建moment.service.js文件,处理数据库逻辑

5、在login.middleware.js中判断 token 是否未携带

4、接口


查询动态列表
1、生成动态数据

2、在moment.router.js中,添加查询动态的路由GET: /

3、在moment.controller.js中,处理查询动态的路由逻辑list()

4、在moment.service.js中,处理数据库逻辑queryList()

5、接口

查询动态列表-分页查询
1、在list()中获取分页参数offset, size

2、在queryList()中,执行分页 SQL 语句

注意: 此处的 offset 和 size 不支持数字类型,需要转成 String
3、接口

4、当接口没有传参时,需要给一个默认值


查询动态列表-多表查询@
1、SQL 语句

2、修改queryList()

3、查询结果

动态详情
1、在router中,添加路由GET /:momentId

2、在controller中,实现detail()

3、在service中,实现queryById()

4、接口

修改动态
1、在router中,添加PATCH /:momentId路由,需要验证 token

2、在controller中,实现update()

3、在service中,实现update()


4、接口


权限验证@
在修改动态前需要验证用户身份,用户只能修改自己发表的动态
1、在router中,添加权限验证中间件verifyMomentPermission()

2、创建permission.middleware.js,实现verifyMomentPermission()中间件

3、创建permission.service.js,实现checkMoment()

4、没有权限,处理错误


权限验证-优化@
问题: 当前的权限验证只能验证用户是否有操作moment的权限,不能验证用户的其他权限(comment)
优化: 让权限验证更具通用性
思路一:封装一个返回中间件的函数verifyPermission()
1、在permission.middleware.js中,封装一个返回中间件的函数verifyPermission()【未实现】

2、在router中,使用verifyPermission()

思路二:根据/:momentId获取resourceName,从而动态生成 SQL 语句
1、在router中,使用verifyPermission()

2、在permission.middleware.js中,封装

3、在service中,实现checkResource()

补充:操作动态前,先查询资源是否存在
[TODO]
删除动态
1、在router中,添加DELETE /:momentId路由,需要验证 token 和权限

2、在controller中,实现remove()

3、在service中,实现remove()


4、接口


评论
发表评论
1、创建评论表comment


2、创建comment.router.js文件,添加POST /路由,需要验证 token

3、创建comment.controller.js文件,实现create()

4、创建comment.service.js文件,实现create()

5、接口

回复评论
1、在router中,添加POST / reply路由,需要验证 token

2、在controller中,实现reply

3、在service中,实现reply

4、接口


删除评论【
修改评论【
查询动态和评论@
查询动态时,同时展示评论信息
- 查询多个动态时,显示评论个数
- 查询单个动态时,显示评论列表
查询多个动态时,显示评论个数
1、SQL 语句


2、修改queryList()

3、查询结果

查询单个动态时,显示评论列表
1、SQL 语句


2、修改queryById()

3、查询结果

4、在评论对象中添加user对象


动态标签
标签和动态是多对多的关系

标签表

创建标签
1、创建label.router.js文件,添加POST /路由,需要验证 token

2、创建label.controller.js文件,实现create()

3、创建label.service.js文件,实现create()

4、接口



查询标签列表【
1、在router中,添加GET /list路由,不需要验证 token

2、在controller中,实现list()
3、在service中,实现list()
4、接口

多对多关系表
创建一个动态和标签之间的多对多关系表


动态-添加标签

- 验证是否登录
- 验证是否具有操作动态的权限
- 验证 label 是否已经存在于 label 表中
- 存在:直接使用该 label
- 不存在:先将 label 添加到 label 表中,再使用该 label
- 将动态和 labels 的关系添加到关系表中
1、在moment.router.js中,添加POST /:momentId/labels路由

2、创建label.middleware.js文件,实现verifyLabelExsts()中间件
验证 label 是否已经存在于 label 表中
- 存在:直接使用该 label
- 不存在:先将 label 添加到 label 表中,再使用该 label

3、在label.service.js中,实现queryLabelByName()

4、在moment.controller.js中,实现addLabels(),为动态添加标签


5、在label.service.js中,实现hasLabel(),判断是否已经存在某个 moment 和 label 的关系

6、在label.service.js中,实现addLabel(),为动态添加标签

7、接口

动态-查询标签
查询多个动态时,显示标签个数
1、SQL

2、修改queryList()

3、查询结果

查询单个动态时,显示标签数组
错误的做法:这种做法查到的 labels 会根据
comments的数量,重复查询该数量的次数,因为查询comments时出现了JSON_ARRAYAGG()函数SQL

正确的做法:使用子查询
1、SQL

2、修改
queryById()
3、查询结果

图片上传存储
头像上传
依赖包:
- multer
- @koa/multer
1、基本使用
1、创建file.router.js文件,添加POST /avatar路由,需要验证 token

2、封装上传头像中间件
1、创建file.middleware.js文件,封装上传头像中间件


2、抽取uploads常量


3、创建file.controller.js文件,实现create()


5、接口


保存头像信息
保存上传头像的信息到数据表中
1、创建avatar表


2、在file.controller.js中,实现create(),保存上传头像的信息

3、在file.service.js中,实现create(),保存上传头像的信息到数据库中

4、上传成功


展示头像
1、在user.router.js中,添加GET /avatar/:userId路由

2、在user.controller.js中,实现showAvatarImage(),

3、在file.service.js中,实现queryAvatarByUserId(),获取最新上传的头像信息

4、查看头像

动态-查询头像
1、修改user表,增加头像地址字段


2、在file.controller.js的create()中,保存用户上传的头像地址

3、在user.service.js中,实现updateUserAvatar(),保存用户上传的头像地址

4、抽取域名常量



5、在moment.service.js中,查询动态信息时,修改 SQL 语句,添加 user 中的 avatar_url
动态列表

动态详情

CMS接口
角色接口
新增角色
1、接口
2/44
2、在role.router.js中创建路由

3、需要在app/index.js中手动注册路由

4、在路由中添加增删改查

5、在controller/role.controller.js中编写中间件函数

6、在service/role.service.js中操作数据库

7、创建role表

查询角色列表
1、接口

2、在controller/role.controller.js中编写中间件函数
注意:
- 当使用
connect.query()时,要求传入的变量必须是number类型 - 当使用
connect.execute()时,要求传入的变量必须是string类型

3、在service/role.service.js中操作数据库

菜单接口
新增菜单
1、接口

2、创建数据库

3、效果

4、在menu.router.js中创建路由,需要验证token

5、在app/index.js中注册路由

6、在menu.controller.js中实现create()

7、在menu.service.js中操作数据库

查询完整菜单树列表
1、接口

2、SQL语句

3、在menu.controller.js中实现list()

4、在menu.service.js中操作数据库

多对多-给角色分配菜单权限
1、接口

2、执行结果

3、创建role和menu的关系表

4、在role.router.js中,添加路由

5、在role.controller.js中,实现assignMenu(),给角色分配菜单

6、在role.service.js中,操作数据库

多对多-查询角色列表权限
2、在role.controller.js中,除了获取角色的基本信息外,还要获取角色的菜单信息

3、在role.service.js中,根据roleId获取所有的menuId


4、在role.service.js中,根据menuId获取菜单树

项目部署
知识点
postman
创建全局变量
1、添加 coderhub 环境变量


2、在接口路径中使用{ {变量名} }引用变量

定义 token 变量
1、在用户登录接口定义 token 全局变量

2、使用 token

封装 app
1、封装 app

2、导入 app

错误处理
1、执行一次错误处理代码

2、在出错的位置发射错误事件

3、监听处理错误

4、优化: 抽取错误常量

使用常量


密码加密
1、在路由中添加加密中间件

2、封装加密中间件

3、封装 md5 加密的工具函数

数据库连接
依赖包:mysql2
1、使用mysql2连接数据库

2、抽取数据库连接

3、导入数据库连接

4、判断数据库是否连接成功

token
颁发 token
1、依赖包:jsonwebtoken
pnpm add jsonwebtoken2、创建 keys
# 开启openssl
openssl
# 创建私钥
genrsa -out private.key 2048
# 根据私钥创建公钥
rsa -in private.key -pubout -out public.key3、读取并导出私钥和公钥

注意: 这里的路径./keys会有问题,因为readFileSync()是从启动目录nodemon ./src/xx的位置开始计算的。
注意: 当algorithm: 'RS256' 时,要求生成的私钥长度最少为 2048
要写成如下路径:

或者写成这样:

4、使用私钥和公钥颁发 token

5、接口


验证 token
1、创建/test路由

2、封装 test

3、验证未通过,报错


4、接口

5、封装: 验证 token 的中间件verifyAuth



自动注册路由
1、在 router 目录下创建index.js文件,并定义registerRouters()

2、在app/index.js文件下,使用registerRouters()

登录凭证
cookie
概述
概述:Cookie(复数形态 Cookies),又称为“小甜饼”,类型为“小型文本文件”。
作用:网站为了辨别用户身份而存储在用户本地终端(Client Side)上的数据。浏览器会在特定的情况下携带上 cookie 来发送请求,可以通过 cookie 来获取一些信息。
Cookie 分类:
Cookie 总是保存在客户端中,按在客户端中的存储位置,Cookie 可以分为内存 Cookie 和硬盘 Cookie。
内存 Cookie: 由浏览器维护,保存在内存中,浏览器关闭时 Cookie 就会消失,其存在时间是短暂的;
硬盘 Cookie: 保存在硬盘中,有一个过期时间,用户手动清理或者过期时间到时,才会被清理;
区分 Cookie:
如何判断一个 cookie 是内存 cookie 还是硬盘 cookie 呢?
内存 Cookie:没有设置过期时间,默认情况下 cookie 是内存 cookie,在关闭浏览器时会自动删除
硬盘 Cookie:有设置过期时间,并且过期时间不为 0 或者负数,是硬盘 cookie,需要手动或者到期时,才会删除
生命周期
cookie 的生命周期:
默认情况下的 cookie 是内存 cookie,也称之为会话 cookie,也就是在浏览器关闭时会自动被删除
我们可以通过设置 expires 或者 max-age 来设置过期的时间
expires:设置的是
Date.toUTCString(),设置格式是:expires=date-in-GMTString-formatmax-age:设置过期的秒钟,
max-age=max-age-in-seconds(例如一年为 60*60*24*365)
作用域
cookie 的作用域:允许 cookie 发送给哪些 URL
domain:指定哪些主机可以接受 cookie
如果不指定,那么默认是 origin,不包括子域名,只能访问如:
www.baidu.com,不能访问:map.baidu.com如果指定,则包含子域名。如果设置
domain=baidu.com,则 cookie 也包含在子域名中,如map.baidu.com
path:指定主机下哪些路径可以接受 cookie
例如,设置
path=/docs,则以下地址都会匹配:/docs/docs/Web//docs/Web/HTTP
设置 Cookie
客户端设置
- document.cookie:
string,获取 cookie - document.cookie = 'key=value;max-age=xxx':设置 cookie,同时设置过期时间(单位:s)
注意:
- 设置 cookie 时,不设置
max-age或max-age=0时是内存 cookie,浏览器关闭时 cookie 会消失 - 设置 cookie 时,设置了
max-age时是硬盘 cookie,只有等过期时间到达时,cookie 才会消失
JS 直接设置和获取 cookie:

这个 cookie 会在会话关闭时被删除掉;

设置 cookie,同时设置过期时间(默认单位是秒钟)

服务端设置
- ctx.cookies.set(name, value, options?):``,设置 cookie。允许在服务端创建或更新一个 cookie,并发送给客户端
- name:
string,cookie 的名称 - value:
string,cookie 的值。如果要删除一个 Cookie,通常将值设置为null - opotions?:
object,- maxAge:
number,表示从现在开始 Cookie 存活的毫秒数 - expires:
Date,指定了 cookie 的过期日期。如果同时设置了maxAge,则忽略此属性。 - domain:
string,cookie 的域名。默认没有设置,只对当前域名有效 - path:
string,cookie 的路径。默认是'/',这意味着 Cookie 对整个站点有效
- maxAge:
- name:
- ctx.cookies.get(name):``,从请求中获取指定名称的 cookie 值
- name:
string,要获取的 cookie 的名称 - 返回:
undefined | value
- name:
服务端设置 cookie:
Koa 中默认支持直接操作 cookie。
用户登录成功设置 cookie:
当客户端给服务端发送一个登录成功的请求后,服务器会在服务端给客户端的电脑设置一个 cookie。


用户访问网站验证 cookie:
下次客户端再来访问时就可以携带上这个 cookie 用于标识自己,服务端会验证 cookie 以识别用户。
【查看请求头的 cookie】

session
Session 是基于 cookie 实现机制。
在 koa 中,我们可以借助于 koa-session 来实现 session 认证:
依赖包:
- sh
npm i koa-session
基本使用
1、创建一个 session,并作为中间件使用

2、使用 session 保存信息

3、通过 session 获取之前保存的信息

4、生成的 sessionid

优化:加密 session
1、设置signed: true,并通过app.keys进行加盐操作

2、生成的 sessionid

3、服务端在获取 value 时,会同时验证sessionid和sessionid.sig,只有 2 者都正确时才会通过

其他 session 选项

token
cookie、session 的缺点:
Cookie 会被附加在每个 HTTP 请求中,所以无形中增加了流量(事实上某些请求是不需要的);
Cookie 是明文传递的,所以存在安全性的问题(可以通过和 session 结合解决该问题);
Cookie 的大小限制是 4KB,对于复杂的需求来说是不够的;
对于浏览器外的其他客户端(比如 iOS、Android),必须手动的设置 cookie 和 session;
对于分布式系统和服务器集群中如何可以保证其他系统也可以正确的解析 session?
token 概述:
所以,在目前的前后端分离的开发过程中,使用 token 来进行身份验证的是最多的情况:
token可以翻译为令牌;
也就是在验证了用户账号和密码正确的情况,给用户颁发一个令牌;
这个令牌作为后续用户访问一些接口或者资源的凭证;
我们可以根据这个凭证来判断用户是否有权限来访问;
所以 token 的使用应该分成两个重要的步骤:
1、生成 token:登录的时候,颁 token;
2、验证 token:访问某些资源或者接口时,验证 token;
JWT 实现 Token
JWT 生成的 Token 由三部分组成,每个部分之间用.间隔:
header:会通过 base64Url 算法进行编码
- alg:采用的加密算法,默认是 HMAC SHA256(HS256),HS256 是一种对称加密,采用同一个密钥进行加密和解密。
- typ:
'JWT',固定值,通常都写成 JWT 即可。
payload:携带的数据,如我们可以将用户的 id 和 name 放到 payload 中,会通过 base64Url 算法进行编码。
- iat:默认也会携带 iat(issued at),令牌的签发时间。
- exp:我们也可以设置过期时间:exp(expiration time)。
signature:用于验证消息在传输过程中未被篡改,并且,对于使用私钥签名的 Token,还可以验证发行者的身份。
设置一个
secretKey,通过将前两个的结果合并后进行 HMACSHA256 的算法。HMACSHA256(base64Url(header)+.+base64Url(payload), secretKey)但是如果 secretKey 暴露是一件非常危险的事情,因为之后就可以模拟颁发 token,也可以解密 token。

jsonwebtoken
在真实开发中,我们可以直接使用一个库来完成: jsonwebtoken
依赖包:jsonwebtoken
基本使用
1、颁发 token并返回给客户端

3、后续请求时携带 token

3、验证 token


API
- jwt.sign():用于生成一个新的 JWT。
- jwt.verify():验证给定的 JWT,并返回解码后的 payload。
- jwt.decode():仅解码 JWT 的 payload 部分,而不验证其签名。
jwt.sign()
jwt.sign():用于生成一个新的 JWT。
jwt.sign(payload, secretOrPrivateKey, options?, callback?)参数
payload(Object | Buffer | String): 要编码到 JWT 中的数据。如果是对象或 Buffer,则会先进行 JSON 编码。secretOrPrivateKey(String | Buffer): 用于签名的密钥或私钥。options?(Object): 可选参数,用于控制 JWT 的生成方式,例如:algorithm(String)(默认为HS256): 指定签名算法。expiresIn(单位:s): 设置 Token 的过期时间。notBefore: 定义 Token 何时生效。audience: 设置 Token 的观众(aud)。issuer: 设置 Token 的发行者(iss)。jwtid: 设置 JWT 的 ID。allowInsecureKeySizes:为真则允许当使用 RSA 加密时私钥长度为 2048 以下
callback?(Function): 可选的回调函数,用于异步获取 JWT。如果提供了回调函数,jwt.sign()将异步执行。
注意: 当algorithm: 'RS256' 时,要求生成的私钥长度最少为 2048byte
示例:

jwt.verify()
jwt.verify():验证给定的 JWT,并返回解码后的 payload。
jwt.verify(token, secretOrPublicKey, [options, callback])参数
token(String): 要验证的 JWT 字符串。secretOrPublicKey(String | Buffer): 用于验证签名的密钥或公钥。options(Object): 可选参数,提供验证选项,如:algorithms: (Array)指定接受的签名算法列表。audience: 验证 Token 的观众(aud)值。issuer: 验证 Token 的发行者(iss)值。ignoreExpiration: 是否忽略 Token 的过期时间。
callback(Function): 可选的回调函数,用于异步操作。如果提供了回调函数,jwt.verify()将异步执行。
示例:

jwt.decode()
jwt.decode():仅解码 JWT 的 payload 部分,而不验证其签名。
jwt.decode(token, options?)参数
token(String): 要解码的 JWT 字符串。options(Object): 可选参数,目前支持一个选项:complete: 如果设置为 true,返回的将是一个包含 header、payload 和 signature 的完整对象,而不仅仅是 payload。
注意:
- 使用
jwt.sign()生成的 Token 应该在客户端安全地存储,例如 HTTP Cookie 或浏览器的 localStorage 中,并且通过 HTTPS 传输以避免被拦截。 jwt.verify()在验证 Token 时非常重要,它确保了 Token 的真实性和完整性。你应该总是在信任的服务器端验证 Token。jwt.decode()不验证 Token 的有效性,因此不应该用于任何需要安全性的场景。它只是简单地解码 Token,以便查看其内容。
非对称加密
前面我们说过,HS256 加密算法一单密钥暴露就是非常危险的事情:
比如在分布式系统中,每一个子系统都需要获取到密钥;
那么拿到这个密钥后这个子系统既可以发布另外,也可以验证令牌;
但是对于一些资源服务器来说,它们只需要有验证令牌的能力就可以了;
这个时候我们可以使用非对称加密,RS256:
私钥(private key):用于发布令牌;
公钥(public key):用于验证令牌;
我们可以使用 openssl 来生成一对私钥和公钥:
Mac 直接使用 terminal 终端即可;
Windows 默认的 cmd 终端是不能直接使用的,建议直接使用 git bash 终端;
# 1. 开启openssl
openssl
# 2. 创建私钥
genrsa -out private.key 1024
# 3. 根据私钥创建公钥
rsa -in private.key -pubout -out public.key使用公钥和私钥颁发和验证签名

派发令牌和验证令牌

