theme: channing-cyan
账单及其相关接口
前言
接口是本次实战项目的核心模块,用户可以通过账单模块记录自己日常消费和收入情况。本节需要编写五个接口:
- 账单列表
- 添加账单
- 修改账单
- 删除账单
- 账单详情
知识点
- 一套
CRUD
- 多层级复杂数据结构的处理
egg-mysql
的使用
一.新增账单接口
需要先实现新增一个账单,才能比较方便的制作后续的其他接口。先来回顾一下前面设计好的账单表 bill
。
根据上述表的属性,可以轻松的知道新增账单接口需要哪些字段,于是打开 /controller
,在目录下新增 bill.js
脚本文件,添加一个 add
方法,代码如下:
```js 'use strict';
const moment = require('moment')
const Controller = require('egg').Controller;
class BillController extends Controller { async add() { const { ctx, app } = this; // 获取请求中携带的参数 const { amount, typeid, typename, date, pay_type, remark = '' } = ctx.request.body;
// 判空处理,这里前端也可以做,但是后端也需要做一层判断。
if (!amount || !type_id || !type_name || !date || !pay_type) {
ctx.body = {
code: 400,
msg: '参数错误',
data: null
}
}
try {
let user_id
const token = ctx.request.header.authorization;
// 拿到 token 获取用户信息
const decode = app.jwt.verify(token, app.config.jwt.secret);
if (!decode) return
user_id = decode.id
// user_id 默认添加到每个账单项,作为后续获取指定用户账单的标示。
// 可以理解为,我登录 A 账户,那么所做的操作都得加上 A 账户的 id,后续获取的时候,就过滤出 A 账户 id 的账单信息。
const result = await ctx.service.bill.add({
amount,
type_id,
type_name,
date,
pay_type,
remark,
user_id
});
ctx.body = {
code: 200,
msg: '请求成功',
data: null
}
} catch (error) {
ctx.body = {
code: 500,
msg: '系统错误',
data: null
}
}
} }
module.exports = BillController; ```
新增账单接口唯一需要注意的是,往数据库里写数据的时候,需要带上用户 id
,这样便于后续查找、修改、删除,能找到对应用户的账单信息。所以本章节的所有接口,都是需要经过鉴权中间件过滤的。必须要拿到当前用户的 token
,才能拿到用户的 id
信息。
处理逻辑已经写完,需要把 service
服务也安排上,打开 service
,在目录下新建 bill.js
,添加代码如下: ``` 'use strict';
const Service = require('egg').Service;
class BillService extends Service { async add(params) { const { app } = this; try { // 往 bill 表中,插入一条账单数据 return await app.mysql.insert('bill', params); } catch (error) { console.log(error); return null; } } }
module.exports = BillService; ```
app.mysql.insert
是数据库插件 egg-mysql
封装好的插入数据库操作。它是一个异步方法,所以很多地方都是需要异步操作的。
不要忘记将接口抛出,很多时候写完了逻辑,忘记抛出接口,就报 404 错误。
js // router.js router.post('/api/bill/add', _jwt, controller.bill.add); // 添加账单
打开调试接口好伙伴 Postman
,验证它:
要注意的是 Headers
中要带上 token
信息如下:
id
是自增属性,所以添加一条数据,默认就是 1 ,再添加一条,id
则为 2。
这里可能会有有疑问,这里的 type_id
和 type_name
属性从哪里来?
在添加账单列表的时候,会选择该笔账单的类型,如餐饮、购物、学习、奖金等等,这个账单类型就是之前定义的 type
表里获取的。于是在这里实现手动定义好这张表的初始数据,如下所示:
每个属性代表的意义可以返回《数据库表的设计》查看详情。这里的 user_id
属性为 0 ,代表的是通用的账单类型,所有用户都可以使用。如果后续有需要添加自定义属性,那么 user_id
则需要指定某个用户的 id
。
二.账单列表获取
账单列表的获取,可以先查看前端需要做成怎样的展示形式:
分析上图,账单是以时间作为维度,比如我在 2021 年 1 月 1 日记录了 2 条账单,在 2021 年 1 月 2 日记录了 1 条账单,返回的数据就是这样的: js [ { date: '2020-1-1', bills: [ { // bill 数据表中的每一项账单 }, { // bill 数据表中的每一项账单 } ] }, { date: '2020-1-2', bills: [ { // bill 数据表中的每一项账单 }, ] } ]
并且前端还需要做滚动加载更多,所以服务端是需要给分页的。于是就需要在获取 bill
表里的数据之后,进行一系列的操作,将数据整合成上述格式。
当然,获取的时间维度以月为单位,并且可以根据账单类型进行筛选。上图左上角有当月的总支出和总收入情况,也在列表数据中给出,因为它和账单数据是强相关的。
于是,打开 /controller/bill.js
添加一个 list
方法,来处理账单数据列表: ```js // 列表获取 async list() { const { ctx, app } = this; // 获取,日期 date,分页数据,类型 typeid,这些都是在前端传给后端的数据 const { date, page = 1, pagesize = 5, type_id = 'all' } = ctx.query
try { let userid // 通过 token 解析,拿到 userid const token = ctx.request.header.authorization; const decode = app.jwt.verify(token, app.config.jwt.secret); if (!decode) return userid = decode.id // 拿到当前用户的账单列表 const list = await ctx.service.bill.list(userid) console.log('list', list) console.log('date', date) // 过滤出月份和类型所对应的账单列表 const list = list.filter(item => { if (typeid !== 'all') { return moment(Number(item.date)).format('YYYY-MM') === date && typeid === item.typeid } return moment(Number(item.date)).format('YYYY-MM') === date }) console.log('_list', _list) // 格式化数据,将其变成之前设置好的对象格式 let listMap = _list.reduce((curr, item) => { // curr 默认初始值是一个空数组 [] // 把第一个账单项的时间格式化为 YYYY-MM-DD const date = moment(Number(item.date)).format('YYYY-MM-DD') // 如果能在累加的数组中找到当前项日期 date,那么在数组中的加入当前项到 bills 数组。 if (curr && curr.length && curr.findIndex(item => item.date === date) > -1) { const index = curr.findIndex(item => item.date === date) curr[index].bills.push(item) } // 如果在累加的数组中找不到当前项日期的,那么再新建一项。 if (curr && curr.length && curr.findIndex(item => item.date === date) === -1) { curr.push({ date, bills: [item] }) } // 如果 curr 为空数组,则默认添加第一个账单项 item ,格式化为下列模式 if (!curr.length) { curr.push({ date, bills: [item] }) } return curr }, []).sort((a, b) => moment(b.date) - moment(a.date)) // 时间顺序为倒叙,时间约新的,在越上面
// 分页处理,listMap 格式化后的全部数据,还未分页。
const filterListMap = listMap.slice((page - 1) * page_size, page * page_size)
// 计算当月总收入和支出
// 首先获取当月所有账单列表
let __list = list.filter(item => moment(Number(item.date)).format('YYYY-MM') === date)
// 累加计算支出
let totalExpense = __list.reduce((curr, item) => {
if (item.pay_type === 1) {
curr += Number(item.amount)
return curr
}
return curr
}, 0)
// 累加计算收入
let totalIncome = __list.reduce((curr, item) => {
if (item.pay_type === 2) {
curr += Number(item.amount)
return curr
}
return curr
}, 0)
// 返回数据
ctx.body = {
code: 200,
msg: '请求成功',
data: {
totalExpense, // 当月支出
totalIncome, // 当月收入
totalPage: Math.ceil(listMap.length / page_size), // 总分页
list: filterListMap || [] // 格式化后,并且经过分页处理的数据
}
}
} catch { ctx.body = { code: 500, msg: '系统错误', data: null } } }
``` 代码逻辑的分析,全部以注释的形式编写,这样方便边看代码,边分析逻辑,上述代码逻辑较长,需要好好分析,实现逻辑越复杂,这样才会慢慢进步。
上述代码使用到了 service
服务 ctx.service.bill.list
,所以后续需要在 /service/bill.js
下新建 list
方法,如下所示: js // 获取账单列表 async list(id) { const { app } = this; const QUERY_STR = 'id, pay_type, amount, date, type_id, type_name, remark'; let sql = `select ${QUERY_STR} from bill where user_id = ${id}`; try { return await app.mysql.query(sql); } catch (error) { console.log(error); return null; } }
这次利用执行 sql
语句的形式,从数据库中获取需要的数据,app.mysql.query
方法负责执行你的 sql
语句,上述 sql
语句,解释成中文就是,“从 bill 表中查询 userid 等于当前用户 id 的账单数据,并且返回的属性是 id, paytype, amount, date, typeid, typename, remark”。
将接口抛出: js // router.js router.get('/api/bill/list', _jwt, controller.bill.list); // 获取账单列表
前往 Postman
验证一下是否生效:
三.账单修改接口
继续制作账单修改接口,修改接口和新增接口的区别在于,新增是在没有的情况下,编辑好参数,添加进数据库内部。而修改接口则是编辑现有的数据,根据当前账单的 id
,更新数据。
所以这里需要实现两个接口: 1. 获取账单详情接口 2. 更新数据接口
先来完成获取账单详情接口,在 /controller/bill.js
添加 detail
方法,代码如下所示: ```js // 获取账单详情 async detail() { const { ctx, app } = this; // 获取账单 id 参数 const { id = '' } = ctx.query // 获取用户 userid let userid const token = ctx.request.header.authorization; // 获取当前用户信息 const decode = app.jwt.verify(token, app.config.jwt.secret); if (!decode) return user_id = decode.id // 判断是否传入账单 id if (!id) { ctx.body = { code: 500, msg: '订单id不能为空', data: null } return }
try { // 从数据库获取账单详情 const detail = await ctx.service.bill.detail(id, userid) ctx.body = { code: 200, msg: '请求成功', data: detail } } catch (error) { ctx.body = { code: 500, msg: '系统错误', data: null } } } 编写完上述逻辑之后,还需要前往 `/service/bill.js` 添加 `ctx.service.bill.detail` 方法,如下所示:
js // 获取账单详情 async detail(id, userid) { const { app } = this; try { return await app.mysql.get('bill', { id, user_id }); } catch (error) { console.log(error); return null; } } 抛出接口:
js router.get('/api/bill/detail', _jwt, controller.bill.detail); // 获取详情 `` 打开
Postman 查看是否能根据
id` 获取到账单:
已经可以通过点击账单列表,前往账单详情页面,进行当前账单的编辑修改工作。
于是乎,就引出了编辑账单接口,在 /controller/bill.js
添加 update
方法,如下所示: ```js // 编辑账单 async update() { const { ctx, app } = this; // 账单的相关参数,这里注意要把账单的 id 也传进来 const { id, amount, typeid, typename, date, paytype, remark = '' } = ctx.request.body; // 判空处理 if (!amount || !typeid || !typename || !date || !paytype) { ctx.body = { code: 400, msg: '参数错误', data: null } }
try { let userid const token = ctx.request.header.authorization; const decode = await app.jwt.verify(token, app.config.jwt.secret); if (!decode) return userid = decode.id // 根据账单 id 和 userid,修改账单数据 const result = await ctx.service.bill.update({ id,//账单 id amount,// 金额 typeid,// 消费类型 id typename, // 消费类型名称 date,// 日期 paytype,// 消费类型 remark,// 备注 userid// 用户 id }); ctx.body = { code: 200, msg: '请求成功', data: null } } catch (error) { ctx.body = { code: 500, msg: '系统错误', data: null } } } `ctx.service.bill.update` 便是操作数据库修改当前账单 `id` 的方法,需要在 `/service/bill.js` 添加相应的方法,如下所示:
js // 编辑账单 async update(params) { const { ctx, app } = this; try { let result = await app.mysql.update('bill', { ...params, }, { id: params.id, userid: params.user_id, }); return result; } catch (error) { console.log(error); return null; } } ``
app.mysql.update` 方法,在(二)已经有所解释,这边再能加深一下印象。
第一个参数为需要操作的数据库表名称 bill
;第二个参数为需要更新的数据内容,这里直接将参数展开;第三个为查询参数,指定 id
和 user_id
。
完事之后,将接口抛出: js router.post('/api/bill/update', _jwt, controller.bill.update); // 账单更新
通过 Postman
验证接口是否可行:
通过详情接口,请求是否修改成功:
不负所望,修改生效了。
四.账单删除接口
删除接口可能是这几个接口中,最容易实现的一个。只需要获取到单笔账单的 id
,通过 id
去删除数据库中对应的账单数据。打开 /controller/bill.js
添加 delete
方法,如下所示: ```js // 删除账单 async delete() { const { ctx, app } = this; const { id } = ctx.request.body;
if (!id) { ctx.body = { code: 400, msg: '参数错误', data: null } }
try { let userid const token = ctx.request.header.authorization; const decode = app.jwt.verify(token, app.config.jwt.secret); if (!decode) return userid = decode.id const result = await ctx.service.bill.delete(id, user_id); ctx.body = { code: 200, msg: '请求成功', data: null } } catch (error) { ctx.body = { code: 500, msg: '系统错误', data: null } } } ```
并且前往 /service/bill.js
添加 delete
服务,如下所示: js // 删除账单 async delete(id, user_id) { const { app } = this; try { let result = await app.mysql.delete('bill', { // 简写 id, user_id, }); return result; } catch (error) { console.log(error); return null; } } }
app.mysql.delete
方法接收两个参数,第一个是数据库表名称,第二个是查询条件。这里给的查询条件是账单 id
和用户 user_id
。其实可以不传用户 user_id
,因为的账单 id
都是自增的,不会有重复值出现,不过安全起见,带上 user_id
起到双保险的作用。
将接口抛出: js // router.js router.post('/api/bill/delete', _jwt, controller.bill.delete); // 删除账单
报错信息为 “token 已过期,请重新登录”。这说明之前生成 token
时,配置的时效生效了。
当然,你可以将有效期设置成 1 分钟,这样方便测试有效期是否生效。
重新通过登录接口获取新的 token
,如下所示:
通过新的 token
再次发起删除接口请求:
请求成功,数据库 bill
表里已经空空如也。
五.数据图表模块
完成上述账单模块的一套 CRUD
之后,基本上对一张表的 增上改差
处理,已经轻车熟路了。学习这件事情,很多时候就是靠不断地练习,甚至同一件事情,你不可能做一次就熟练,熟才能生巧。所以接下来再对数据模块进行处理和分析,制作出数据图表接口,在实现接口之前,先看看需要实现的需求:
首先是头部的汇总数据,并且接口支持事件筛选,以 月
为单位。
其次是收支的构成图,对每一个类型的支出和收入进行累加,最后通过计算占比以此从大到小排布。如上图所示,当前月份的所有学习支出是 2553
,这个累加计算,在服务端完成。
最后引入 echarts
,完成一个饼图的简单排布,其实也就是上图收支比例图的一个变种。
js { total_data: [ { number: 137.84, // 支出或收入数量 pay_type: 1, // 支出或消费类型值 type_id: 1, // 消费类型id type_name: "餐饮" // 消费类型名称 } ], total_expense: 3123.54, // 总消费 total_income: 6555.80 // 总收入 }
六.数据接口实现
经过上述分析,想必同已是胸有成竹。既然数据是和账单强相关,将方法写在 /controller/bill.js
中,添加 data
方法,首先根据用户信息,获取到账单表的相关数据,如下所示: ```js async data() { const { ctx, app } = this; const { date = '' } = ctx.query // 获取用户 userid // 。。。 // 省略鉴权获取用户信息的代码 try { // 获取账单表中的账单数据 const result = await ctx.service.bill.list(userid); // 根据时间参数,筛选出当月所有的账单数据 const start = moment(date).startOf('month').unix() * 1000; // 选择月份,月初时间 const end = moment(date).endOf('month').unix() * 1000; // 选择月份,月末时间 const _data = result.filter(item => (Number(item.date) > start && Number(item.date) < end)) } catch {
} } ```
上述 _data
便是经过筛选过滤出来的当月账单基础数据,每一条数据都是之前用户手动添加的,所以会有很多同类项。接下来,工作就是将这些同类项进行合并。
数组方法 reduce
的用处,超出你的想象。在一些累加操作上,它的优势展露无疑。就比如上述需求,需要在一串数组中,将每一项的支出 amount
值,累加起来最后返回给 total_expense
。你当然可以通过 forEach
方法,在外面声明一个变量,循环的累加它,如下所示:
```js let total_expense = 0
data.forEach(item => { if (item.paytype == 1) { total_expense += Number(item.amount) } }) ```
但是,在外面声明一个变量,这样看起来显得不是那么的美观。很多时候,你不想到处声明变量,此时 reduce
便能很好地解决这个问题,因为它第二个参数,可以声明一个值,作为循环的初始值,并在每一次的回调函数
当作第一个参数 arr
被传入。
于是继续追加总收入的逻辑,如下所示: js // 总收入 const total_income = _data.reduce((arr, cur) => { if (cur.pay_type == 2) { arr += Number(cur.amount) } return arr }, 0)
到这里,已经将汇总数据完成。接下来完成收支构成部分: ```js // 获取收支构成 let totaldata = _data.reduce((arr, cur) => { const index = arr.findIndex(item => item.typeid === cur.typeid) if (index === -1) { arr.push({ typeid: cur.typeid, typename: cur.typename, paytype: cur.pay_type, number: Number(cur.amount) }) } if (index > -1) { arr[index].number += Number(cur.amount) } return arr }, [])
totaldata = totaldata.map(item => { item.number = Number(Number(item.number).toFixed(2)) return item })
ctx.body = { code: 200, msg: '请求成功', data: { totalexpense: Number(totalexpense).toFixed(2), totalincome: Number(totalincome).toFixed(2), totaldata: totaldata || [], } } } catch (error) { ctx.body = { code: 500, msg: '系统错误', data: null } } ```
分析上述 reduce
的回调函数,arr
初始值为一个空数组,进入回调函数逻辑,首先通过 findIndex
方法,查找 arr
内,有无和当前项 cur
相同类型的账单,比如学习、餐饮、交通等等。
如果 index
没有找到,则会返回 -1,此时说明当前 cur
的消费类型,在 arr
中是没有的,所以通过 arr.push
新增一个类型的数据,数据结构如上所示。
如果找到相同的消费类型,index 值则为大于 -1 的值,所以找到 arr[index]
,让它的 number
属性加上当前项的 amount
,以此实现相同消费类型的累加。
最后,将所有的 number
数据保留两位小数,并且将数据返回。
不要忘记将接口抛出: js router.get('/api/bill/data', _jwt, controller.bill.data); // 获取数据
七.总结
本节学习了一个完整的增删改查套件,这可以作为你的种子套件,后续如果有新的需求思路,要添加新的表和方法,可以按照这样一套作为基础进行临摹。比如我想做一个笔记本需求,那我就可以新建一张 note
表,再实现一套类似朋友圈的需求,有文字有图片,可删除可添加。
并且对通过数据图表的接口,对数据库表的数据进行二次处理进行了复习,巩固之前的知识点。