小程序node+Koa后端开发
1.开发环境配置
- 框架/库
- Node.js(10.15.3)
- npm
- Koa
- nodemon pm2
- 软件/工具
- MySQL(XAMPP)
- 微信开发者工具
- VSCode
- PostMan
- Navict(数据库可视化工具)
2.起步
- 安装koa
yarn init -y
yarn add koa
- 启动node
const Koa = require('koa')
const app = new Koa()
const port = 3000
app.listen(port,()=> {
console.log(`程序启动,请访问http://localhost:${port}`);
})
3. koa
3.1 koa中间件
中间件的调用总会返回一个promise
调用next(),返回值是一个promise,在中间件中返回值会返回到promise中
app.use(async (ctx,next)=> {
console.log('hello word');
let a = await next() // 这里接收promise返回值123
console.log(a);
})
app.use(()=> {
console.log('hello word 2');
return '123'
})
3.2 async/await
- 异步编程的一种解决方案(终极解决方案)
- async会将函数包装成一个peomise
- await会阻塞当前线程,等待执行完成,拿到结果才会继续执行,异步->同步
app.use(async () => {
// console.log('hello word 2');
const axios = require('axios')
const start = Date.now()
// await 阻塞当前线程
const res = await axios.get('http://www.7yue.pro')
const end = Date.now()
console.log(end - start);
})
3.3 为什么一定要保证洋葱模型
参考https://blog.csdn.net/weixin_34187822/article/details/88875628
- 使用洋葱模型可以使中间件很好的进行数据传递
- koa使用中间件,全部使用async/await可以保证洋葱模型的执行顺序不变
- 由于第二个中间件执行了异步操作,await阻塞了代码执行,所以先执行了2,等待await执行完成,再执行4
- 如果调用了第三方中间件,我们并不知道其执行顺序,也就很难保证洋葱模型执行顺序
app.use( (ctx, next) => {
console.log('1');
next()
console.log('2');
})
app.use(async (ctx, next) => {
console.log('3');
const axios = require('axios')
// await 阻塞当前线程
const res = await axios.get('http://www.7yue.pro')
next()
console.log(4);
})
// 执行顺序 1 3 2 4
- 所有中间件都使用async,在next()之前加await,可以保证异步编程变得像同步,也就可以保证洋葱模型的执行顺序
- 在一个中间件通过ctx变量赋值,可以使其他中间件在调用next()后(要想获取到这个值,要保证其中间件的代码全部执行完成,所以要在next()后获取其值),接收到这个中间件传递的值,
app.use(async (ctx, next) => {
console.log('1');
await next()
console.log(ctx.r);
console.log('2');
})
app.use(async (ctx, next) => {
console.log('3');
const axios = require('axios')
// await 阻塞当前线程
const res = await axios.get('http://www.7yue.pro')
ctx.r = res
await next()
console.log(4);
})
4. koa路由
- koa通过ctx对象获取路由信息,及返回信息操作
ctx.path
获取路由路径ctx.method
获取请求方法ctx.body
可以直接返回json对象
- 一个项目有很多路由,直接通过判断编写路由,项目会变得复杂不易维护,所以要引用第三方库,进行路由编写
4.1 koa-router
安装第三方包https://www.npmjs.com/package/koa-router
yarn add koa-router
- 基础使用
var Koa = require('koa');
var Router = require('koa-router');
var app = new Koa();
var router = new Router();
router.get('/', (ctx, next) => {
// ctx.router available
ctx.body = {
key: 'classic'
}
});
app
.use(router.routes())
4.2 主题与模型划分
- web开发,好的代码的优点
- 便于阅读
- 利于维护
- 提高编程效率
- 数据请求,编写路由会有很多,可以按照分类进行划分,可以根据
- 数据类型划分
- 数据模型,有利于更好的设计数据库
4.3 api版本
- 为什么api要有版本
打个比方,当前路由返回的数据,
{key:'classic'}
由于业务的变动,返回的数据会进行更改,
{key: 'music'}
我们要考虑客户端的兼容性,一些用户会用老版本的数据,一些用户会使用新版本的数据,如果直接修改代码,可能导致老版本无法正常使用
所以很多情况下,服务器api需要兼容多个版本api,支持3个版本是比较好的,太多的话,开发维护的难度会逐渐增加
- 如何支持api版本
客户端请求路由,需要携带api版本号,携带方式有三种
- url路由
v1/classic/...
- 查询参数
classic/?version=v1...
- 放入header中
- 开闭原则
- 修改代码时存在风险的,尽量单独对原有代码进行扩展
- 我们在编写代码时,对代码的修改是关闭的,对代码的扩展是开放的
4.4 实现路由自动注册require-directory
官网介绍https://www.npmjs.com/package/require-directory
const Router = require('koa-router')
const requireDirectory = require('require-directory')
// 导入路径的所有模块
const modules = requireDirectory(module, './api', {
visit: whenLoadModule
})
// 每当导入一个模块就会执行这个函数
function whenLoadModule(obj) {
// 判断当前模块是否是路由模块
if (obj instanceof Router) {
app.use(obj.routes())
}
}
5. nodemon自动重启server
- 全局安装nodemon
npm i -g nodemon
- 启动服务
nodemon app.js
- 编写scripts脚本
"scripts": {
"start": "nodemon app.js"
},
执行yarn start
启动服务
5.1vscode配置nodemon调试
- 既想通过断点调试,又想通过nodemon重启服务,配置.vscode
- 配置vscode,点击侧边栏爬虫按钮—>点击下拉小箭头—>添加配置
// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "nodemon",
"runtimeExecutable": "nodemon",
"program": "${workspaceFolder}/app.js",
"restart": true,
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
},
{
"type": "node",
"request": "launch",
"name": "启动程序",
"program": "${workspaceFolder}\\app.js"
},
{
"type": "node",
"request": "launch",
"name": "当前文件",
"program": "${file}" // 当前文件
}
]
}
在小爬虫选择调试方式时,选择nodemon即可兼容调试和自动重启
6. 初始化管理器
分离app.js文件代码
— core 公共方法/类
// core/init.js
const requireDirectory = require('require-directory')
const Router = require('koa-router')
class InitManager {
static initCore(app) {
// 入口方法
// InitManager.initLoadRouters(app)
InitManager.app = app
InitManager.initLoadRouters()
}
// 初始化路由
static initLoadRouters() {
// 导入路径的所有模块
const apiDir = `${process.cwd()}/app/api`;
requireDirectory(module, apiDir, { visit: whenLoadModule })
// 每当导入一个模块就会执行这个函数
function whenLoadModule(obj) {
// 判断自动加载的模块是否为路由类型
if (obj instanceof Router) {
InitManager.app.use(obj.routes())
}
}
}
}
module.exports = InitManager
// app.js
const Koa = require('koa')
// 引入初始化管理器
const InitManager = require('./core/init')
const app = new Koa()
InitManager.initCore(app)
const port = 3000 //端口号
app.listen(port, () => {
console.log(`程序启动,请访问http://localhost:${port}`);
})
7. 获取参数与linValidator校验器
向服务器传递参数方式
- url路径传参(params)
- ?后面进行传参(query)
- header进行传参
- body进行传参
7.1通过koa-bodyparser
中间件获取body参数
官网https://www.npmjs.com/package/koa-bodyparser
// app.js
const parser = require('koa-bodyparser')
app.use(parser())
// -app/api/classic.js
const Router = require('koa-router')
const router = new Router()
router.post('/v1/classic/latest/:id', (ctx, next) => {
const path = ctx.params
const query = ctx.query
const headers = ctx.header
const body = ctx.request.body
ctx.body = {
key: 'classic'
}
})
module.exports = router
7.2校验参数
- 校验出不合法参数,返回给客户端
- 某些参数的必填项
- 参数要符合规范(比如:手机号,email…)
校验参数有重要的两点: - 防止非法参数
- 要给客户端明确的提示
8. 异常理论与异常处理
8.1 异常理论
- 为什么要进行异常处理
告诉用户,或自己排查错误时,需要判断异常,查找错误 - 函数执行时会发生的情况
- 无异常,正确返回结果
- 发生了异常
- 根据函数设计《代码大全2》,判断异常方式
- return false/null (此方式会导致函数调用时丢失异常)
- throw new Error (此方式由于函数调用太多,所有函数都进行处理会使代码变复杂)
- 全局异常处理,创建一种机制,监听任何异常
8.2 异步异常处理
- 将函数变成promise
- 如果某一个函数返回的是promise
- 使用async/await简化函数调用链条
- 如果函数调用链中返回promise,调用链中其他函数都使用async/await调用函数
function f1() {
f2()
}
async function f2() {
try {
await f3()
} catch (error) {
console.log('error');
}
}
function f3() {
return new Promise((resolve, reject) => {
// 将异步函数包装成promise
setTimeout(() => {
reject('err')
})
})
/* return await setTimeout(() => {
throw new Error('err')
}); */
}
f1()
8.3 编写全局异常处理中间件
面向切面编程
// middlewares/exception.js
const catchError = async (ctx, next) => {
try {
await next()
} catch (error) {
ctx.body = '服务器有点问题,请等待...'
}
}
module.exports = catchError
8.4 处理异常信息编写
- 输出的错误error,要简化清晰明了的把信息传给前端
- 返回给前端的信息有以下:
- message
- error_code 详细,开发者自己定义的 10001 20003
- request_url 当前请求的url
- 处理异常错误分为两种
- 已知型错误
- 参数校验错误
- 明确处理错误
- try catch
- 未知型错误
- 程序潜在的错误,无意识的,根本就不知道他出错了
- 连接数据库时,账号密码输错了
// middlewares/exception.js
const catchError = async (ctx, next) => {
try {
await next()
} catch (error) {
if(error.errorCode) {
// 已知异常
ctx.body = {
msg: error.message,
error_code: error.errorCode,
request_url: error.requestUrl
},
ctx.status = error.status
}else {
// 未知异常
ctx.body = {
msg: 'we made a mistake, unknown error',
error_code: 999,
request: `${ctx.method} ${ctx.path}`
}
ctx.status = 500
}
/**
* error 堆栈调用信息
* error 简化清晰明了的信息,给前端
* Http Status Code 2xx,4xx,5xx
**返回的信息
* message
* error_code 详细,开发者自己定义的 10001 20003
* request_url 当前请求的url
**错误类型
* *已知型错误
* 参数校验错误
* 明确处理错误
* try catch
* *未知型错误
* 程序潜在的错误,无意识的,根本就不知道他出错了
* 连接数据库时,账号密码输错了
*/
}
}
module.exports = catchError
// 使用
if (true) {
const error = new Error('为什么错误')
error.errorCode = 10001
error.status = 400
error.requestUrl = `${ctx.method} ${ctx.path}`
throw error
}
8.5 定义HttpException基类
处理异常信息编写,返回给前台的信息,每次都是返回固定的几个参数,我们可以封装一个继承原生js的Error的处理http异常的类,来简化我们的代码
// core/http-exception.js
class HttpException extends Error {
constructor(msg = "服务器异常", errorCode = 10000, status = 400) {
super()
this.errorCode = errorCode
this.status = status
this.msg = msg
}
}
module.exports = {
HttpException
}
调用
// middlewares/exception.js
if(error instanceof HttpException) {
ctx.body = {
msg: error.message,
error_code: error.errorCode,
request: `${ctx.method} ${ctx.path}`
},
ctx.status = error.status
}
8.6 扩展异常基类与global全局变量
继承基类,扩展特定的异常类
class ParameterException extends HttpException {
constructor(msg, errorCode) {
super()
this.code = 400
this.msg = msg || "参数错误"
this.errorCode = errorCode || 10000
}
}
class NotFound extends HttpException {
constructor(msg, errorCode) {
super()
this.code = 404
this.msg = msg || "资源未找到"
this.errorCode = errorCode || 10000
}
}
每次调都要先引用对应的类,我们可以使用全局变量,在应用初始化时就加载异常类,供全局调用
//* 初始化核心方法
static initCore(app) {
// 入口方法
InitManager.app = app
InitManager.initLoadRouters()
InitManager.loadHttpException()
}
//* global加载异常处理方法
static loadHttpException() {
const errors = require('./http-exception')
global.errs = errors
}
使用
const error = new global.errs.ParameterException()
8.7 lin-validator校验器的使用
lin-validator.js
依赖的文件http-exception.js
,util.js
- 定义编写校验文件
//validator.js
const { LinValidator, Rule } = require('../../core/lin-validator')
/**
* 校验正整数
* @class PositiveIntergerValidator
* @extends {LinValidator}
*/
class PositiveIntergerValidator extends LinValidator {
constructor() {
super()
// 校验名称, 返回结果, 可选参数
this.id = [new Rule('isInt', '需要是正整数', { min: 1 })]
}
}
module.exports = {
PositiveIntergerValidator
}
Rule
校验器中的参数和validator.js
模块参数相同,参考官网:https://github.com/validatorjs/validator.js
- 使用校验器校验参数
// api/v1/classic.js
const Router = require('koa-router')
const router = new Router()
const { PositiveIntergerValidator } = require('../../validators/validator')
router.post('/v1/:id/classic/latest', (ctx, next) => {
const params = ctx.params
const query = ctx.query
const headers = ctx.header
const body = ctx.request.body
// 校验ctx中的参数
const v = new PositiveIntergerValidator().validate(ctx)
})
module.exports = router
- 参数的获取
const v = await new PositiveIntergerValidator().validate(ctx)
// 使用验证器获取参数
//获取值并进行类型转换 get方法使用的是loadsh的get方法,如果想获取原数据,第二个参数设置为false
const id = v.get('path.id', false)
console.log(id);
8.8 全局配置文件设置
设置开发环境抛出错误,供开发查看,生产环境不需要抛出错误
- 编写配置文件
// config/config.js
module.exports = {
env: "dev",
}
- 在项目初始化时,将配置文件赋值给
global
对象,供全局使用
// core/init.js
static initCore(app) {
// 入口方法
InitManager.loadConfig()
}
//* 加载配置文件
static loadConfig(path = '') {
const configPath = path || process.cwd() + '/config/config.js'
const config = require(configPath)
global.config = config
}
- 捕获错误时,区分开开发环境和生产环境
if (global.config.env === 'dev') {
throw error
}
9. 操作mySql数据
9.1 连接数据库
- 安装依赖
yarn add mysql2 sequelize
- 配置连接数据库相关参数
// config/config.js
database: {
dbName: "isLand", // 数据库名
host: "localhost",
port: 3306,
user: "root",
password: ""
},
- 使用sequelize创建连接数据库模块
// core/db.js
const Sequelize = require('sequelize')
const {
dbName,
host,
port,
user,
password
} = require('../config/config.js').database
const sequelize = new Sequelize(dbName, user, password, {
dialect: 'mysql',
host,
port,
logging: true, //显示数据库操作
timezone: '+08:00', //时区,不设置会与北京相差8小时
define: {
// create_time update_time delete_time
timestamps: true, //创建删除更新时间
paranoid: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
deletedAt: 'deleted_at',
underscored: true,
freezeTableName: true
}
})
//同步更新数据库
sequelize.sync({
force: true
})
module.exports = {
sequelize
}
sequelize可以自定义一些连接数据库的配置,详情见官网1http://www.nodeclass.com/api/sequelize.html;
官网2https://sequelize.org/master/
- 使用sequelize创建数据库模型User
// app/models/user.js
const { sequelize } = require('../../core/db') //sequelize实例
const { Sequelize, Model } = require('sequelize')
class User extends Model {
}
User.init({
/**
* 主键: 不能重复 不能为空
* 注册: User id 设计 id编号系统 60001 60002
* 自动增长id编号
* id编号自己设计最好是数字,字符串,
* 不要使用随机字符串,例如:GUID
*
* 暴露了用户编号
* 即使别人知道用户编号,也无法做坏事
* 接口保护 权限 访问接口 Token
*/
id: {
type: Sequelize.INTEGER,
primaryKey: true, // 设置主键
autoIncrement: true, // 自动增长
},
nickname: Sequelize.STRING,
email: Sequelize.STRING,
password: Sequelize.STRING,
openid: {
type: Sequelize.STRING(64), //限制最大范围
unique: true, //指定唯一
},
/**
* 用户 --小程序 openid 不变 且唯一
* A,B
*
* 你 小程序/公众号 unionID 是唯一的
*/
}, {
sequelize,
tableName: 'user' // 数据迁移
})
// 数据迁移 SQL更新 风险
- 引入user模型,创建数据库表
// app.js
require('./app/models/user')
10. 用户注册流程与sequelize新增数据
1.用户注册逻辑
- 通过路由接收路由参数
- 通过LinValidator校验路由参数
- 校验成功将数据保存到数据库
2. 邮箱注册
- 编写路由
// app/api/v1/user.js
const Router = require('koa-router')
const { RegisterValidator } = require('../../validators/validator')
const {success} = require('../../lib/helper')
const { User } = require('../../models/user')
const router = new Router({
prefix: "/v1/user" //自动配置url前缀
})
/**
* 注册
*/
// router.post('/register', new RegisterValidator() async (ctx) => {
/**
* 使用中间件的形式做校验,全局只有1个validator
*
*/
router.post('/register', async (ctx) => {
/**
* 编写路由思维路径
* 1. 接收参数 LinValidator
* email password1 password2 nickname
* 2. 将参数保存数据库
* v.get
* sql Model
*/
// 使用实例化方式,调用10次会实例化10次
const v = await new RegisterValidator().validate(ctx)
const user = {
email: v.get('body.email'),
password: v.get('body.password1'),
nickname: v.get('body.nickname')
}
await User.create(user)
// 使用抛出错误的方法,抛出一个成功
// throw new global.errs.Success()
// 封装成一个函数引入
success('注册成功')
})
module.exports = router
- 编写注册校验Validator
// app\validators\validator.js
class RegisterValidator extends LinValidator {
constructor() {
super()
this.email = [
new Rule('isEmail', '不符合Email规范')
]
this.password1 = [
// 用户密码指定范围,密码强度
new Rule('isLength', '密码至少6个字符,最多32个字符', {
min: 6,
max: 32
}),
new Rule('matches', '密码必须包含数字、大写英文字母、小写英文字母', '^(?![0-9]+$)(?![a-zA-Z]+$)[0-9A-Za-z]')
]
this.password2 = this.password1
this.nickname = [
new Rule('isLength', '昵称至少6个字符,最多32个字符', {
min: 2,
max: 32
})
]
}
// 自定义校验方法,前缀必须是validate
validatePassword(vals) {
const psw1 = vals.body.password1
const psw2 = vals.body.password2
if (psw1 !== psw2) {
throw new Error('两个密码必须相同')
}
}
async validateEmail(vals) {
const email = vals.body.email
const user = await User.findOne({
where: {
email: email
}
})
if (user) {
throw new Error('Email已经存在')
}
}
}
- 将数据保存到数据库
// app\api\v1\user.js
const user = {
email: v.get('body.email'),
password: v.get('body.password1'),
nickname: v.get('body.nickname')
}
await User.create(user)
- 保存数据时,保存的密码是经过加密的,使用bcrypt插件加密
// app\models\user.js
password: {
type: Sequelize.STRING,
/**
* note: model的属性操作
* 设计模式 观察者模式
* es6: Reflect Vue3.0
*/
set(val) {
//note: 密码加密 盐
const salt = bcrypt.genSaltSync(10)
/**
* 10的意思: 指的是生成盐的成本,越大,花费成本越高,密码安全性越高,一般取默认值
* 明文,相同密码加密之后也要不同,防止彩虹攻击
*/
const psw = bcrypt.hashSync(val, salt)
this.setDataValue('password', psw)
}
},
- 保存数据成功,返回成功消息
- 可以使用抛出错误的方法,抛出一个成功
throw new global.errs.Success()
- 封装成一个函数引入,更容易理解
// app\lib\helper.js
function success(msg,errorCode) {
throw new global.errs.Success(msg,errorCode)
}
module.exports={
success
}
// app\api\v1\user.js
const {success} = require('../../lib/helper')
success('注册成功')'
11. 用户登录操作流程
用户登录逻辑
- 接收登录参数(账号,密码,登录类型)
- 校验登录参数
- 根据不同的登录类型,执行不同的登录方法
- 核对数据库用户身份是否正确
- 登录成功返回成功信息
1. 编写用户登录路由
// app\api\v1\token.js
const Router = require('koa-router')
const { TokenValidator } = require('../../validators/validator')
const { LoginType } = require('../../lib/enums')
const { User } = require('../../models/user')
/**
* 登录
* session 考虑状态 无状态
* email password
* 2. 令牌获取 颁布令牌
* token 无意义的随机字符串
* jwt 可以携带数据
*/
const router = new Router({
prefix: "/v1/token" //自动配置url前缀
})
router.post('/', async (ctx) => {
const v = await new TokenValidator().validate(ctx)
// 根据type类型,执行不同的登录方法
switch (v.get('body.type')) {
case LoginType.USER_EMAIL:
await emailLogin(v.get('body.account'), v.get('body.secret'))
break;
case LoginType.USER_MINI_PROGRAM:
break;
default:
throw new global.errs.ParameterException('没有相应的处理函数')
break;
}
})
/**
* email登录
*
* @param {*} account 账户
* @param {*} secret 密码
*/
async function emailLogin(account, secret) {
const user = await User.verifyEmailPassword(account, secret)
}
module.exports = router
2. 校验用户登录路由参数
// app\validators\validator.js
class TokenValidator extends LinValidator {
constructor() {
super()
// 账号
this.account = [
new Rule('isLength', '不符合账号规则', { min: 4, max: 32 })
]
// 密码
this.secret = [
/**
* 是必须要传入的吗
* web 账号+密码
* 登录 多元化 小程序登录不需要校验密码
* 微信打开小程序 已经验证了合法用户了
* web account + secret
* 小程序 account
* 手机号登录
* 1. 可以为空,可以不传
* 2. 空 不为空
*/
new Rule('isOptional'),
new Rule('isLength', '至少6个字符', { min: 6, max: 128 })
]
// 验证登录方式 type JS 枚举
}
validateLoginType(vals) {
if (!vals.body.type) {
throw new Error('type是必传参数')
}
if (!LoginType.isThisType(vals.body.type)) {
throw new Error('type参数不合法')
}
}
}
isOptional
校验规则,可以该参数为可传,可传
3. 判断登录类型type,执行登录方法
// app\api\v1\token.js
/**
* email登录
*
* @param {*} account 账户
* @param {*} secret 密码
*/
async function emailLogin(account, secret) {
const user = await User.verifyEmailPassword(account, secret)
}
4. 核对用户邮箱密码
// app\models\user.js
class User extends Model {
/**
* 核对用户邮箱密码
*
* @static
* @param {*} email
* @param {*} plainPassword
* @memberof User
*/
static async verifyEmailPassword(email,plainPassword) {
const user = await User.findOne({
where: {
}
})
if(!user) throw new global.errs.AuthFailed('用户不存在')
// 密码验证
const correct = bcrypt.compareSync(plainPassword,user.password)
if(!correct) throw new global.errs.AuthFailed('密码不正确')
return user
}
}
5. 生成jwt令牌
- 使用第三方生成token的库
yarn add jsonwebtoken
- 封装生成token的函数
// 配置jwt秘钥
// config/config.js
security: { // jwt秘钥
secretKey: "qwert", // 令牌key,一般要设置很复杂
expiresIn: 60 * 60 * 24 * 30 // 过期时间
},
// 封装生成token的函数
// core/util.js
const jwt = require("jsonwebtoken")
const config = require("../config/config")
/**
* 生成jwt Token
*
* @param {*} uid 用户id
* @param {*} scope 用户权限
* @returns
*/
const generateToken = function(uid, scope) {
const secretKey = config.security.secretKey
const expiresIn = config.security.expiresIn
const token = jwt.sign({ uid, scope }, secretKey, { expiresIn })
return token
}
module.exports = {
generateToken
}
- 登录成功获取token
// app/v1/token.js
const { User } = require('../../models/user')
const { generateToken } = require('../../../core/util')
/**
*
* email登录
* 普通用户
*
* @param {*} account 账户
* @param {*} secret 密码
*/
async function emailLogin(account, secret) {
const user = await User.verifyEmailPassword(account, secret)
return token = generateToken(user.id, Auth.USER)
}
6. 验证token令牌合法性
token从前端传递过来,后台获取token的方式:
- 通过路由body header获取
- HTTP 规定 身份验证机制 HttpBasicAuth
检测前端传过来的token是否合法,通过以下几方面进行判断
- 通过
basic-auth
插件解析token,获取token中的用户信息
- 如果没有有解析的值,或者解析的值没有用户信息则抛出错误:token不合法
- 使用
jsonwebtoken
中的verify
方法,验证解析token中的用户信息和后台服务配置的秘钥(secretKey)是否一致,如果信息错误,抛出错误
- 如果error.name == ‘TokenExpiredError’,则说明token过期
- 其他情况说明token不合法
- 验证token权限(后面会讲)
- 如果token的权限值小于api的权限值,则权限不足
- token合法,返回合法的信息(这里是uid和scope)
ctx.auth = {
uid: decode.uid,
scope: decode.scope
}
- 执行下一个中间件
await next()
- 示例代码
// core/http-exception.js
//定义错误类型
class Forbbiden extends HttpException {
constructor(msg, errorCode) {
super()
this.msg = msg || '禁止访问'
this.errorCode = errorCode || 10006
this.code = 403
}
}
// middlewares/auth.js
/**
* 权限控制检测
* 中间件
*/
const basicAuth = require('basic-auth')
const jwt = require('jsonwebtoken')
class Auth {
constructor(level) {
// 定义权限
this.level = level || 1
//note: 定义权限类常量
Auth.USER = 8; // 用户
Auth.ADMIN = 16 // admin
Auth.SUPER_ADMIN = 32 // 超级admin
}
get m() {
return async (ctx, next) => {
/**
* token 检测
* 1. token获取 body header
* HTTP 规定 身份验证机制 HttpBasicAuth
* 2. 判断token合法性
*/
// console.log(ctx);
const userToken = basicAuth(ctx.req)
let errMsg = 'token不合法'
if (!userToken || !userToken.name) {
throw new global.errs.Forbbiden(errMsg)
}
try {
// 校验令牌,用户传过来的token, 全局配置文件中的令牌key
//note: decode是jwt令牌返回的信息,里面有自定义的变量,例如uid
var decode = jwt.verify(userToken.name,
global.config.security.secretKey)
} catch (error) {
/**
* 明确提示用户到底哪种情况不合法
* token不合法
* token过期
*/
if (error.name == 'TokenExpiredError') {
//! 过期
errMsg = 'token已过期'
}
//! 不合法
throw new global.errs.Forbbiden(errMsg)
}
if (decode.scope < this.level) {
errMsg = '权限不足'
throw new global.errs.Forbbiden(errMsg)
}
// uid,scope
ctx.auth = {
uid: decode.uid,
scope: decode.scope
}
await next()
}
}
}
module.exports = {
Auth
}
7. api权限问题
- API 权限 非公开api需要token才能访问
- token 过期/不合法 就不能访问api
- 编写权限Map
// app/lib/enums.js
// 权限类型
const AuthType = {
LATEST: 7, //课程列表
}
- 编写验证jwt令牌权限的中间件
// middlewares/auth.js
/**
* 权限控制检测
* 中间件
*/
const basicAuth = require('basic-auth')
const jwt = require('jsonwebtoken')
class Auth {
constructor(level) {
// 定义权限
this.level = level || 1
//note: 定义权限类常量
Auth.USER = 8; // 用户
Auth.ADMIN = 16 // admin
Auth.SUPER_ADMIN = 32 // 超级admin
}
get m() {
return async (ctx, next) => {
/**
* token 检测
* 1. token获取 body header
* HTTP 规定 身份验证机制 HttpBasicAuth
* 2. 判断token合法性
*/
// console.log(ctx);
const userToken = basicAuth(ctx.req)
let errMsg = 'token不合法'
if (!userToken || !userToken.name) {
throw new global.errs.Forbbiden(errMsg)
}
try {
// 校验令牌,用户传过来的token, 全局配置文件中的令牌key
//note: decode是jwt令牌返回的信息,里面有自定义的变量,例如uid
var decode = jwt.verify(userToken.name,
global.config.security.secretKey)
} catch (error) {
/**
* 明确提示用户到底哪种情况不合法
* token不合法
* token过期
*/
if (error.name == 'TokenExpiredError') {
//! 过期
errMsg = 'token已过期'
}
//! 不合法
throw new global.errs.Forbbiden(errMsg)
}
if (decode.scope < this.level) {
errMsg = '权限不足'
throw new global.errs.Forbbiden(errMsg)
}
// uid,scope
ctx.auth = {
uid: decode.uid,
scope: decode.scope
}
await next()
}
}
}
module.exports = {
Auth
}
- 修改classic.js路由,做权限分级
const { Auth } = require('../../../middlewares/auth')
const { AuthType } = require('../../lib/enums')
router.get('/latest', new Auth(AuthType.LATEST).m, async (ctx, next) => {
/**
* 1.权限是一个很难很复杂的问题
* 目前的auth中间件只是实现了一种限制
* 2.权限分级 scope
* 普通用户 管理员
* 8 16
* 如果普通用户携带的权限数字是8,如果把/latest api的权限数字设置为9,
* 普通用户权限8是小于api权限9的,所以用户无法访问此api
* 但是管理员用户的权限数字是16,大于9,所以管理员可以访问此api
*/
ctx.body = ctx.auth.uid
})
- 权限分级
权限是一个很难很复杂的问题 | 普通用户 | 管理员 | | —- | —- | | 8 | 16 |
- 如果普通用户携带的权限数字是8,如果把/latest api的权限数字设置为9,
- 普通用户权限8是小于api权限9的,所以用户无法访问此api
- 但是管理员用户的权限数字是16,大于9,所以管理员可以访问此api
8. 编写小程序通过openid登录系统的后台服务
小程序登录原理
- 小程序生成code发送给服务端
- 服务端拿着code请求微信服务端
- 请求成功微信服务端返回openid(唯一标识);鉴定用户是否合法
- 小程序端没有显式的注册
- 请求微信服务
- 微信服务传参形式
https://developers.weixin.qq.com/miniprogram/dev/api-backend/open-api/login/auth.code2Session.html
请求微信服务器的请求参数:
code //动态生成
appid
appsecret
- 通过微信服务url传递参数请求服务器
- 接收微信服务返回的openid
- 为用户建立档案 将数据写入user表,同时生成一个uid编号
- 不建议使用openid作为uid的编号,
- (1)openid比较长,作为主键查询效率比较低
- (2)openid实际上是比较机密的数据,如果在小程序和服务端进行传递容易泄露
- 考虑token失效的情况
- 如果token失效,再次登录传入code,就会再次走codeToToken的流程
- 我们会再次拿到openid,我们需要查询数据库是否有此openid,
- (1)如果有同样的openid则不再保存数据库
- (2)如果没有存在则创建新的user档案
- 判断登录类型,执行微信相关业务逻辑
// api/v1/token.js
const { WXManager } = require('../../services/wx')
case LoginType.USER_MINI_PROGRAM:
//小程序
token = await WXManager.codeToToken(v.get('body.account'))
break;
- 请求微信服务相关配置
// config/config.js
wx: {
appid: "wx77124add68e6adcb",
appsecret: "0621bffd050cfcbb0139c014652e0453",
loginUrl: "https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code"
}
- user模型,查询和添加openid操作
// models/user.js
/**
* 获取openid
*
* @static
* @param {*} openid
* @returns
* @memberof User
*/
static async getUserByOpenid(openid) {
const user = await User.findOne({
where: {
openid
}
})
return user
}
/**
* 添加openid
*
* @static
* @param {*} openid
* @returns
* @memberof User
*/
static async registerByOpenid(openid) {
return await User.create({
openid
})
}
- 微信相关业务逻辑
// app/services/wx.js
/**
* 微信相关业务逻辑
*/
const util = require('util')
const axios = require('axios')
const { User } = require('../models/user')
const { generateToken } = require('../../core/util')
const { Auth } = require('../../middlewares/auth')
class WXManager {
static async codeToToken(code) {
/**
* 小程序登录逻辑
* 1. 小程序生成code发送给服务端
* 2. 服务端拿着code请求微信服务端
* 3. 请求成功微信服务端返回openid(唯一标识);鉴定用户是否合法
* 小程序端没有显示的注册
* 4. 请求微信服务
* 微信服务传参形式
* https://developers.weixin.qq.com/miniprogram/dev/api-backend/open-api/login/auth.code2Session.html
* code 动态生成
* appid appsecret
* 通过微信服务url传递参数请求服务器
*/
// 格式化url
const url = util.format(
global.config.wx.loginUrl,
global.config.wx.appid,
global.config.wx.appsecret,
code
)
// console.log(url);
const result = await axios.get(url)
// console.log(result.data);
if (result.status !== 200) {
throw new global.errs.AuthFailed('openid获取失败')
}
const errcode = result.data.errcode
const errmsg = result.data.errmsg
if (errcode) {
throw new global.errs.AuthFailed(
'openid获取失败' + errmsg,
errcode
)
}
/**
* 5. 接收微信服务返回的openid
* 为用户建立档案 将数据写入user表,同时生成一个uid编号
* 不建议使用openid作为uid的编号,
* (1)openid比较长,作为主键查询效率比较低
* (2)openid实际上是比较机密的数据,如果在小程序和服务端进行传递容易泄露
* 6. 考虑token失效的情况
* 如果token失效,再次登录传入code,就会再次走codeToToken的流程
* 我们会再次拿到openid,我们需要查询数据库是否有此openid,
* (1)如果有同样的openid则不再保存数据库
* (2)如果没有存在则创建新的user档案
*/
let user = await User.getUserByOpenid(result.data.openid)
if (!user) {
user = await User.registerByOpenid(result.data.openid)
}
return generateToken(user.id, Auth.USER)
}
}
module.exports = {
WXManager
}
9. 在小程序登录,验证接口
// 获取code
wx.login({
success: res => {
if(res.code) {
wx.request({
url: 'http://localhost:9000/v1/token',
method: 'POST',
data: {
account: res.code,
type: 100
},
success:res=> {
console.log(res)
const code = res.statusCode.toString()
// 字符串以2开头的
if(code.startsWith('2')) {
wx.setStorageSync('token',res.data.token)
console.log(wx.getStorageSync('token'))
}
}
})
}
}
})
12. 验证token令牌是否有效
验证token令牌是否有效逻辑
- 服务端接收前台传递的token参数
- 校验token参数是否为空
- 验证token有效性
- 返回验证结果给前台
服务端
- 路由请求接口
// api/v1/token.js
// 验证令牌是否有效
router.post('/verify', async (ctx) => {
// token
const v = await new NotEmptyValidator().validate(ctx)
const result = Auth.verifyToken(v.get('body.token'))
ctx.body = {
result
}
})
- 校验token是否为空
/**
* 校验token是否为空
*
* @class NotEmptyValidator
* @extends {LinValidator}
*/
class NotEmptyValidator extends LinValidator{
constructor() {
super()
this.token = [
new Rule('isLength','不允许为空',{min:1})
]
}
}
- 验证令牌是否有效中间件
// middlewares/auth.js
/**
* 验证令牌是否有效
*
* @static
* @param {*} token
* @memberof Auth
*/
static verifyToken(token) {
try {
jwt.verify(token,
global.config.security.secretKey)
return true
} catch (error) {
return false
}
}
小程序端
onVerifyToken() {
wx.request({
url: 'http://localhost:9000/v1/token/verify',
method: 'POST',
data: {
token: wx.getStorageSync('token')+1123,
},
success: res => {
console.log(res)
const code = res.statusCode.toString()
// 字符串以2开头的
if (code.startsWith('2')) {
console.log(res.data)
}
}
})
}
13. 业务逻辑写在哪
- 在API接口编写(简单的)
- Model(对于web分层架构来说都写在Model里)
MVC模式 业务逻辑写在Model里
- 业务分层
- 简单的业务,写在Model里
- 复杂的业务,在Model上面在加一层Service
例如:
Thinkphp Model Service Logic
java Model DTO