Skip to content
On this page

后端实战-账单及其相关接口实现


前言

账单接口是我们本次实战项目的核心模块,用户可以通过账单模块记录自己日常消费和收入情况。本章节我们需要编写五个接口:

1、账单列表 2、添加账单 3、修改账单 4、删除账单 5、账单详情

这样一套增删改查操作下来,基本上可以用这套模式复制出另一套增删改查,所以业务基本上都是互通的,不同之处在于表与表之间能建立什么样的联系,同时也取决于需求方对业务的要求。

知识点

  • 一套 CRUD

  • 多层级复杂数据结构的处理。

  • egg-mysql 的使用。

新增账单接口

我们需要先实现新增一个账单,才能比较方便的制作后续的其他接口。我们先来回顾一下前面设计好的账单表 bill

根据上述表的属性,我们可以轻松的知道新增账单接口需要哪些字段,于是我们打开 /controller,在目录下新增 bill.js 脚本文件,添加一个 add 方法,代码如下:

javascript
'use strict';

const moment = require('moment')

const Controller = require('egg').Controller;

class BillController extends Controller {
  async add() {
    const { ctx, app } = this;
    // 获取请求中携带的参数
    const { amount, type_id, type_name, 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 = await 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,添加代码如下:

javascript
'use strict';

const Service = require('egg').Service;

class BillService extends Service {
  async add(params) {
    const { ctx, app } = this;
    try {
      // 往 bill 表中,插入一条账单数据
      const result = await app.mysql.insert('bill', params);
      return result;
    } catch (error) {
      console.log(error);
      return null;
    }
  }
}

module.exports = BillService;

app.mysql.insert 是数据库插件 egg-mysql 封装好的插入数据库操作。它是一个异步方法,所以我们很多地方都是需要异步操作的。

不要忘记将接口抛出,很多时候写完了逻辑,忘记抛出接口,就报 404 错误。

javascript
// router.js
router.post('/api/bill/add', _jwt, controller.bill.add); // 添加账单

打开我们的调试接口好伙伴 Postman,验证它:

要注意的是 Headers 中要带上 token 信息如下:

此时我们查看数据库内是否已经添加了数据,如下所示:

id 是自增属性,所以添加一条数据,默认就是 1 ,再添加一条,id 则为 2。

有同学会有疑问,这里的 type_idtype_name 属性从哪里来?

我们在添加账单列表的时候,会选择该笔账单的类型,如餐饮、购物、学习、奖金等等,这个账单类型就是我们我们之前定义的 type 表里获取的。于是我们在这里实现手动定义好这张表的初始数据,如下所示:

每个属性代表的意义我们可以返回第 5 章《数据库表的设计》查看详情。这里的 user_id 属性为 0 ,代表的是通用的账单类型,所有用户都可以使用。如果后续有需要添加自定义属性,那么 user_id 则需要指定某个用户的 id

账单列表获取

账单列表的获取,我们可以先查看前端需要做成怎样的展示形式:

分析上图,账单是以时间作为维度,比如我在 2021 年 1 月 1 日记录了 2 条账单,在 2021 年 1 月 2 日记录了 1 条账,单我们返回的数据就是这样的:

javascript
[
  {
    date: '2020-1-1',
    bills: [
      {
        // bill 数据表中的每一项账单
      },
      {
        // bill 数据表中的每一项账单
      }
    ]
  },
  {
    date: '2020-1-2',
    bills: [
      {
        // bill 数据表中的每一项账单
      },
    ]
  }
]

并且我们前端还需要做滚动加载更多,所以服务端是需要给分页的。于是就需要在获取 bill 表里的数据之后,进行一系列的操作,将数据整合成上述格式。

当然,获取的时间维度以月为单位,并且可以根据账单类型进行筛选。上图左上角有当月的总支出和总收入情况,我们也在列表数据中给出,因为它和账单数据是强相关的。

于是,我们打开 /controller/bill.js 添加一个 list 方法,来处理账单数据列表:

javascript
async list() {
  const { ctx, app } = this;
  // 获取,日期 date,分页数据,类型 type_id,这些都是我们在前端传给后端的数据
  const { date, page = 1, page_size = 5, type_id = 'all' } = ctx.query

  try {
     let user_id
      // 通过 token 解析,拿到 user_id
      const token = ctx.request.header.authorization;
      const decode = await app.jwt.verify(token, app.config.jwt.secret);
      if (!decode) return
      user_id = decode.id
      // 拿到当前用户的账单列表
      const list = await ctx.service.bill.list(user_id)
      // 过滤出月份和类型所对应的账单列表
      const _list = list.filter(item => {
        if (type_id != 'all') {
          return moment(Number(item.date)).format('YYYY-MM') == date && type_id == item.type_id
        }
        return moment(Number(item.date)).format('YYYY-MM') == date
      })
      // 格式化数据,将其变成我们之前设置好的对象格式
      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 方法,如下所示:

javascript
// 获取账单列表
  async list(id) {
    const { ctx, 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 {
      const result = await app.mysql.query(sql);
      return result;
    } catch (error) {
      console.log(error);
      return null;
    }
  }

这次我们利用执行 sql 语句的形式,从数据库中获取需要的数据,app.mysql.query 方法负责执行你的 sql 语句,上述 sql 语句,解释成中文就是,“从 bill 表中查询 user_id 等于当前用户 id 的账单数据,并且返回的属性是 id, pay_type, amount, date, type_id, type_name, remark”。

将接口抛出:

javascript
// router.js
router.get('/api/bill/list', _jwt, controller.bill.list); // 获取账单列表

前往 Postman 验证一下是否生效:

账单修改接口

我们继续制作账单修改接口,修改接口和新增接口的区别在于,新增是在没有的情况下,编辑好参数,添加进数据库内部。而修改接口则是编辑现有的数据,根据当前账单的 id,更新数据。

所以这里我们需要实现两个接口:

1、获取账单详情接口

2、更新数据接口

我们先来完成获取账单详情接口,在 /controller/bill.js 添加 detail 方法,代码如下所示:

javascript
// 获取账单详情
async detail() {
  const { ctx, app } = this;
  // 获取账单 id 参数
  const { id = '' } = ctx.query
  // 获取用户 user_id
  let user_id
  const token = ctx.request.header.authorization;
  // 获取当前用户信息
  const decode = await 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, user_id)
    ctx.body = {
      code: 200,
      msg: '请求成功',
      data: detail
    }
  } catch (error) {
    ctx.body = {
      code: 500,
      msg: '系统错误',
      data: null
    }
  }
}

编写完上述逻辑之后,我们还需要前往 /service/bill.js 添加 ctx.service.bill.detail 方法,如下所示:

javascript
async detail(id, user_id) {
  const { ctx, app } = this;
  try {
    const result = await app.mysql.get('bill', { id, user_id })
    return result;
  } catch (error) {
    console.log(error);
    return null;
  }
}

抛出接口:

javascript
router.get('/api/bill/detail', _jwt, controller.bill.detail); // 获取详情

打开 Postman 查看是否能根据 id 获取到账单:

此时我们已经可以通过点击账单列表,前往账单详情页面,进行当前账单的编辑修改工作。

于是乎,就引出了编辑账单接口,我们在 /controller/bill.js 添加 update 方法,如下所示:

javascript
// 编辑账单
async update() {
  const { ctx, app } = this;
  // 账单的相关参数,这里注意要把账单的 id 也传进来
  const { id, amount, type_id, type_name, 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;
    const decode = await app.jwt.verify(token, app.config.jwt.secret);
    if (!decode) return
    user_id = decode.id
    // 根据账单 id 和 user_id,修改账单数据
    const result = await ctx.service.bill.update({
      id, // 账单 id
      amount, // 金额
      type_id, // 消费类型 id
      type_name, // 消费类型名称
      date, // 日期
      pay_type, // 消费类型
      remark, // 备注
      user_id // 用户 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 添加相应的方法,如下所示:

javascript
async update(params) {
  const { ctx, app } = this;
  try {
    let result = await app.mysql.update('bill', {
        ...params
    }, {
        id: params.id,
        user_id: params.user_id
    });
    return result;
  } catch (error) {
    console.log(error);
    return null;
  }
}

app.mysql.update 方法,我们在第 2 章基础入门篇已经有所解释,这边我们再能加深一下印象。

第一个参数为需要操作的数据库表名称 bill;第二个参数为需要更新的数据内容,这里直接将参数展开;第三个为查询参数,指定 iduser_id

完事之后,将接口抛出:

javascript
router.post('/api/bill/update', _jwt, controller.bill.update); // 账单更新

通过 Postman 验证接口是否可行:

通过详情接口,请求是否修改成功:

不负所望,修改生效了。

账单删除接口

删除接口可能是这几个接口中,最容易实现的一个。我们只需要获取到单笔账单的 id,通过 id 去删除数据库中对应的账单数据。我们打开 /controller/bill.js 添加 delete 方法,如下所示:

javascript
async delete() {
  const { ctx, app } = this;
  const { id } = ctx.request.body;

  if (!id) {
    ctx.body = {
      code: 400,
      msg: '参数错误',
      data: null
    }
  }

  try {
    let user_id
    const token = ctx.request.header.authorization;
    const decode = await app.jwt.verify(token, app.config.jwt.secret);
    if (!decode) return
    user_id = 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 服务,如下所示:

javascript
async delete(id, user_id) {
  const { ctx, app } = this;
  try {
    let result = await app.mysql.delete('bill', {
      id: id,
      user_id: user_id
  });
    return result;
  } catch (error) {
    console.log(error);
    return null;
  }
}

app.mysql.delete 方法接收两个参数,第一个是数据库表名称,第二个是查询条件。这里我们给的查询条件是账单 id 和用户 user_id。其实我们可以不传用户 user_id,因为我们的账单 id 都是自增的,不会有重复值出现,不过安全起见,带上 user_id 起到双保险的作用。

我们将接口抛出:

javascript
// router.js
router.post('/api/bill/delete', _jwt, controller.bill.delete); // 删除账单

我们打开老朋友 Postman,验证接口是否可行:

报错信息为 “token 已过期,请重新登录”。这说明我们之前生成 token 时,配置的时效生效了。

当然,你可以将有效期设置成 1 分钟,这样方便测试有效期是否生效。

我们重新通过登录接口获取新的 token,如下所示:

通过新的 token 再次发起删除接口请求:

请求成功,数据库 bill 表里已经空空如也。

数据图表模块

完成上述账单模块的一套 CRUD 之后,同学们基本上对一张表的 增上改差 处理,已经轻车熟路了。学习这件事情,很多时候就是靠不断地练习,甚至同一件事情,你不可能做一次就熟练,熟才能生巧。所以我们接下来再对数据模块进行处理和分析,制作出数据图表接口,我们在实现接口之前,先看看需要实现的需求:

首先是头部的汇总数据,并且接口支持事件筛选,以 为单位。

其次是收支的构成图,对每一个类型的支出和收入进行累加,最后通过计算占比以此从大到小排布。如上图所示,当前月份的所有学习支出是 2553,这个累加计算,我们在服务端完成。

最后我们引入 echarts ,完成一个饼图的简单排布,其实也就是上图收支比例图的一个变种。

我们最终要返回的数据机构如下:

javascript
{
  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 方法,首先根据用户信息,获取到账单表的相关数据,如下所示:

javascript
async data() {
  const { ctx, app } = this;
  const { date = '' } = ctx.query
  // 获取用户 user_id
  // 。。。
  // 省略鉴权获取用户信息的代码
  try {
    // 获取账单表中的账单数据
    const result = await ctx.service.bill.list(user_id);
    // 根据时间参数,筛选出当月所有的账单数据
    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 {
    
  }
}

代码源码请看底部为大家提供的本章节源码 demo。

上述 _data 便是我们经过筛选过滤出来的当月账单基础数据,每一条数据都是之前用户手动添加的,所以会有很多同类项。接下来,我们的工作就是将这些同类项进行合并。

我们先计算总支出,在上述代码追加如下:

javascript
...
// 总支出
const total_expense = _data.reduce((arr, cur) => {
  if (cur.pay_type == 1) {
    arr += Number(cur.amount)
  }
  return arr
}, 0)

数组方法 reduce 的用处,超出你的想象。在一些累加操作上,它的优势展露无疑。就比如上述需求,我们需要在一串数组中,将每一项的支出 amount 值,累加起来最后返回给 total_expense。你当然可以通过 forEach 方法,在外面声明一个变量,循环的累加它,如下所示:

javascript
let total_expense = 0

_data.forEach(item => {
  if (item.pay_type == 1) {
    total_expense += Number(item.amount)
  }
})

但是,在外面声明一个变量,这样看起来显得不是那么的美观。很多时候,你不想到处声明变量,此时 reduce 便能很好地解决这个问题,因为它第二个参数,可以声明一个值,作为循环的初始值,并在每一次的「回调函数」当作第一个参数 arr 被传入。

于是我们继续追加总收入的逻辑,如下所示:

javascript
// 总收入
const total_income = _data.reduce((arr, cur) => {
  if (cur.pay_type == 2) {
    arr += Number(cur.amount)
  }
  return arr
}, 0)

到这里,我们已经将汇总数据完成。接下来完成收支构成部分:

javascript
// 获取收支构成
let total_data = _data.reduce((arr, cur) => {
  const index = arr.findIndex(item => item.type_id == cur.type_id)
  if (index == -1) {
    arr.push({
      type_id: cur.type_id,
      type_name: cur.type_name,
      pay_type: cur.pay_type,
      number: Number(cur.amount)
    })
  }
  if (index > -1) {
    arr[index].number += Number(cur.amount)
  }
  return arr
}, [])

total_data = total_data.map(item => {
  item.number = Number(Number(item.number).toFixed(2))
  return item
})

ctx.body = {
  code: 200,
  msg: '请求成功',
  data: {
    total_expense: Number(total_expense).toFixed(2),
    total_income: Number(total_income).toFixed(2),
    total_data: total_data || [],
  }
}

我们分析上述 reduce 的回调函数,arr 初始值为一个空数组,进入回调函数逻辑,首先我们通过 findIndex 方法,查找 arr 内,有无和当前项 cur 相同类型的账单,比如学习、餐饮、交通等等。

如果 index 没有找到,则会返回 -1,此时说明当前 cur 的消费类型,在 arr 中是没有的,所以我们通过 arr.push 新增一个类型的数据,数据结构如上所示。

如果找到相同的消费类型,index 值则为大于 -1 的值,所以我们找到 arr[index],让它的 number 属性加上当前项的 amount,以此实现相同消费类型的累加。

最后,将所有的 number 数据保留两位小数,并且将数据返回。

不要忘记将接口抛出:

javascript
router.get('/api/bill/data', _jwt, controller.bill.data); // 获取数据

Postman 调试结果如下所示:

总结

本章节带同学们学习了一个完整的增删改查套件,这可以作为你的种子套件,后续如果有新的需求思路,要添加新的表和方法,可以按照这样一套作为基础进行临摹。比如我想做一个笔记本需求,那我就可以新建一张 note 表,再实现一套类似朋友圈的需求,有文字有图片,可删除可添加。

并且对通过数据图表的接口,对数据库表的数据进行二次处理进行了复习,巩固之前的知识点。

本章节源代码

点击下载