小程序node+Koa后端开发
1.开发环境配置
- 框架/库
- Node.js(10.15.3)
- npm
- Koa
- nodemon pm2
- 软件/工具
- MySQL(XAMPP)
- 微信开发者工具
- VSCode
- PostMan
- Navict(数据库可视化工具)
2.起步
- 安装koa
yarn init -yyarn add koa
- 启动node
const Koa = require('koa')const app = new Koa()const port = 3000app.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返回值123console.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 = resawait 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 availablectx.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.jsconst requireDirectory = require('require-directory')const Router = require('koa-router')class InitManager {static initCore(app) {// 入口方法// InitManager.initLoadRouters(app)InitManager.app = appInitManager.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.jsconst 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.jsconst parser = require('koa-bodyparser')app.use(parser())
// -app/api/classic.jsconst Router = require('koa-router')const router = new Router()router.post('/v1/classic/latest/:id', (ctx, next) => {const path = ctx.paramsconst query = ctx.queryconst headers = ctx.headerconst body = ctx.request.bodyctx.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) => {// 将异步函数包装成promisesetTimeout(() => {reject('err')})})/* return await setTimeout(() => {throw new Error('err')}); */}f1()
8.3 编写全局异常处理中间件
面向切面编程
// middlewares/exception.jsconst 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.jsconst 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 = 10001error.status = 400error.requestUrl = `${ctx.method} ${ctx.path}`throw error}
8.5 定义HttpException基类
处理异常信息编写,返回给前台的信息,每次都是返回固定的几个参数,我们可以封装一个继承原生js的Error的处理http异常的类,来简化我们的代码
// core/http-exception.jsclass HttpException extends Error {constructor(msg = "服务器异常", errorCode = 10000, status = 400) {super()this.errorCode = errorCodethis.status = statusthis.msg = msg}}module.exports = {HttpException}
调用
// middlewares/exception.jsif(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 = 400this.msg = msg || "参数错误"this.errorCode = errorCode || 10000}}class NotFound extends HttpException {constructor(msg, errorCode) {super()this.code = 404this.msg = msg || "资源未找到"this.errorCode = errorCode || 10000}}
每次调都要先引用对应的类,我们可以使用全局变量,在应用初始化时就加载异常类,供全局调用
//* 初始化核心方法static initCore(app) {// 入口方法InitManager.app = appInitManager.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.jsconst { 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.jsconst Router = require('koa-router')const router = new Router()const { PositiveIntergerValidator } = require('../../validators/validator')router.post('/v1/:id/classic/latest', (ctx, next) => {const params = ctx.paramsconst query = ctx.queryconst headers = ctx.headerconst body = ctx.request.body// 校验ctx中的参数const v = new PositiveIntergerValidator().validate(ctx)})module.exports = router
- 参数的获取
const v = await new PositiveIntergerValidator().validate(ctx)// 使用验证器获取参数//获取值并进行类型转换 get方法使用的是loadsh的get方法,如果想获取原数据,第二个参数设置为falseconst id = v.get('path.id', false)console.log(id);
8.8 全局配置文件设置
设置开发环境抛出错误,供开发查看,生产环境不需要抛出错误
- 编写配置文件
// config/config.jsmodule.exports = {env: "dev",}
- 在项目初始化时,将配置文件赋值给
global对象,供全局使用
// core/init.jsstatic 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.jsdatabase: {dbName: "isLand", // 数据库名host: "localhost",port: 3306,user: "root",password: ""},
- 使用sequelize创建连接数据库模块
// core/db.jsconst Sequelize = require('sequelize')const {dbName,host,port,user,password} = require('../config/config.js').databaseconst sequelize = new Sequelize(dbName, user, password, {dialect: 'mysql',host,port,logging: true, //显示数据库操作timezone: '+08:00', //时区,不设置会与北京相差8小时define: {// create_time update_time delete_timetimestamps: 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.jsconst { 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.jsrequire('./app/models/user')
10. 用户注册流程与sequelize新增数据
1.用户注册逻辑
- 通过路由接收路由参数
- 通过LinValidator校验路由参数
- 校验成功将数据保存到数据库
2. 邮箱注册
- 编写路由
// app/api/v1/user.jsconst 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.jsclass 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.password1this.nickname = [new Rule('isLength', '昵称至少6个字符,最多32个字符', {min: 2,max: 32})]}// 自定义校验方法,前缀必须是validatevalidatePassword(vals) {const psw1 = vals.body.password1const psw2 = vals.body.password2if (psw1 !== psw2) {throw new Error('两个密码必须相同')}}async validateEmail(vals) {const email = vals.body.emailconst user = await User.findOne({where: {email: email}})if (user) {throw new Error('Email已经存在')}}}
- 将数据保存到数据库
// app\api\v1\user.jsconst user = {email: v.get('body.email'),password: v.get('body.password1'),nickname: v.get('body.nickname')}await User.create(user)
- 保存数据时,保存的密码是经过加密的,使用bcrypt插件加密
// app\models\user.jspassword: {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.jsfunction success(msg,errorCode) {throw new global.errs.Success(msg,errorCode)}module.exports={success}
// app\api\v1\user.jsconst {success} = require('../../lib/helper')success('注册成功')'
11. 用户登录操作流程
用户登录逻辑
- 接收登录参数(账号,密码,登录类型)
- 校验登录参数
- 根据不同的登录类型,执行不同的登录方法
- 核对数据库用户身份是否正确
- 登录成功返回成功信息
1. 编写用户登录路由
// app\api\v1\token.jsconst 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.jsclass 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.jsclass 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.jssecurity: { // jwt秘钥secretKey: "qwert", // 令牌key,一般要设置很复杂expiresIn: 60 * 60 * 24 * 30 // 过期时间},
// 封装生成token的函数// core/util.jsconst 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.secretKeyconst expiresIn = config.security.expiresInconst token = jwt.sign({ uid, scope }, secretKey, { expiresIn })return token}module.exports = {generateToken}
- 登录成功获取token
// app/v1/token.jsconst { 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 || 10006this.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 // adminAuth.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令牌返回的信息,里面有自定义的变量,例如uidvar 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,scopectx.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 // adminAuth.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令牌返回的信息,里面有自定义的变量,例如uidvar 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,scopectx.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 //动态生成appidappsecret
- 通过微信服务url传递参数请求服务器
- 接收微信服务返回的openid
- 为用户建立档案 将数据写入user表,同时生成一个uid编号
- 不建议使用openid作为uid的编号,
- (1)openid比较长,作为主键查询效率比较低
- (2)openid实际上是比较机密的数据,如果在小程序和服务端进行传递容易泄露
- 考虑token失效的情况
- 如果token失效,再次登录传入code,就会再次走codeToToken的流程
- 我们会再次拿到openid,我们需要查询数据库是否有此openid,
- (1)如果有同样的openid则不再保存数据库
- (2)如果没有存在则创建新的user档案

- 判断登录类型,执行微信相关业务逻辑
// api/v1/token.jsconst { WXManager } = require('../../services/wx')case LoginType.USER_MINI_PROGRAM://小程序token = await WXManager.codeToToken(v.get('body.account'))break;
- 请求微信服务相关配置
// config/config.jswx: {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传递参数请求服务器*/// 格式化urlconst 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.errcodeconst errmsg = result.data.errmsgif (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. 在小程序登录,验证接口
// 获取codewx.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) => {// tokenconst 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 Logicjava Model DTO
