账单接口是我们本次实战项目的核心模块,用户可以通过账单模块记录自己日常消费和收入情况。本章节我们需要编写五个接口:
1、账单列表 2、添加账单 3、修改账单 4、删除账单 5、账单详情
这样一套增删改查操作下来,基本上可以用这套模式复制出另一套增删改查,所以业务基本上都是互通的,不同之处在于表与表之间能建立什么样的联系,同时也取决于需求方对业务的要求。

知识点

  • 一套 CRUD。
  • 多层级复杂数据结构的处理。
  • egg-mysql 的使用。

新增账单接口

打开 /controller,在目录下新增 bill.js 脚本文件,添加一个 add 方法,代码如下:

  1. 'use strict';
  2. const moment = require('moment');
  3. const Controller = require('egg').Controller;
  4. class BillController extends Controller {
  5. async add() {
  6. const { ctx, app } = this;
  7. const { amount, type_id, type_name, date, pay_type, remark = '' } = ctx.request.body;
  8. // 判空处理
  9. if (!amount || !type_id || !type_name || !date || !pay_type) {
  10. ctx.body = {
  11. code: 400,
  12. msg: '参数错误',
  13. data: null,
  14. };
  15. }
  16. try {
  17. const token = ctx.request.header.authorization;
  18. const decode = await app.jwt.verify(token, app.config.jwt.secrit);
  19. if (!decode) return;
  20. const user_id = decode.id;
  21. await ctx.service.bill.add({
  22. amount,
  23. type_id,
  24. type_name,
  25. date,
  26. pay_type,
  27. remark,
  28. user_id,
  29. });
  30. ctx.body = {
  31. code: 200,
  32. msg: '请求成功',
  33. data: null,
  34. };
  35. } catch (e) {
  36. ctx.body = {
  37. code: 500,
  38. msg: '系统错误',
  39. data: null,
  40. };
  41. }
  42. }
  43. }
  44. module.exports = BillController;

新增账单接口唯一需要注意的是,往数据库里写数据的时候,需要带上用户 id,这样便于后续查找、修改、删除,能找到对应用户的账单信息。所以本章节的所有接口,都是需要经过鉴权中间件过滤的。必须要拿到当前用户的 token,才能拿到用户的 id 信息。
处理逻辑已经写完,我们需要把 service 服务也安排上,打开 service,在目录下新建 bill.js,添加代码如下:

  1. 'use strict';
  2. const Service = require('egg').Service;
  3. class BillService extends Service {
  4. async add(params) {
  5. const { app } = this;
  6. try {
  7. const result = await app.mysql.insert('bill', params);
  8. return result;
  9. } catch (e) {
  10. console.log(e);
  11. return null;
  12. }
  13. }
  14. }
  15. module.exports = BillService;

添加路由

  1. /** 添加账单 */
  2. router.post('/api/bill/add', _jwt, controller.bill.add);

消费类型接口

添加账单列表的时候,会选择该笔账单的类型,如餐饮、购物、学习、奖金等等,这个账单类型就是我们我们之前定义的 type 表里获取的。于是我们在这里实现手动定义好这张表的初始数据,如下所示:
image.png
为了方便,我们在public下面新建一个type.json文件,具体的json结构如下:

  1. {
  2. "code": 200,
  3. "msg": "请求成功",
  4. "data": {
  5. "list": [
  6. { "id": 1, "name": "餐饮", "type": "1", "user_id": 0 },
  7. { "id": 2, "name": "服饰", "type": "1", "user_id": 0 },
  8. { "id": 3, "name": "交通", "type": "1", "user_id": 0 },
  9. { "id": 4, "name": "日用", "type": "1", "user_id": 0 },
  10. { "id": 5, "name": "购物", "type": "1", "user_id": 0 },
  11. { "id": 6, "name": "学习", "type": "1", "user_id": 0 },
  12. { "id": 7, "name": "医疗", "type": "1", "user_id": 0 },
  13. { "id": 8, "name": "旅行", "type": "1", "user_id": 0 },
  14. { "id": 9, "name": "人情", "type": "1", "user_id": 0 },
  15. { "id": 10, "name": "其他", "type": "1", "user_id": 0 },
  16. { "id": 11, "name": "工资", "type": "2", "user_id": 0 },
  17. { "id": 12, "name": "奖金", "type": "2", "user_id": 0 },
  18. { "id": 13, "name": "转账", "type": "2", "user_id": 0 },
  19. { "id": 14, "name": "理财", "type": "2", "user_id": 0 },
  20. { "id": 15, "name": "退款", "type": "2", "user_id": 0 },
  21. { "id": 16, "name": "其他", "type": "2", "user_id": 0 }
  22. ]
  23. }
  24. }

我们还需要添加一个接口用来获取消费类型。
打开 /controller,在目录下新增 type.js 脚本文件,添加一个 list 方法,代码如下:

  1. 'use strict';
  2. const Controller = require('egg').Controller;
  3. class TypeController extends Controller {
  4. async list() {
  5. const { ctx, app } = this;
  6. const token = ctx.request.header.authorization;
  7. const decode = await app.jwt.verify(token, app.config.jwt.secret);
  8. if (!decode) return;
  9. const user_id = decode.id;
  10. const list = await ctx.service.type.list(user_id);
  11. ctx.body = {
  12. code: 200,
  13. msg: '请求成功',
  14. data: {
  15. list,
  16. },
  17. };
  18. }
  19. }
  20. module.exports = TypeController;

打开 service,在目录下新建 type.js,添加代码如下:

  1. 'use strict';
  2. const Service = require('egg').Service;
  3. class TypeService extends Service {
  4. // 获取标签列表
  5. async list(id) {
  6. const { app } = this;
  7. const QUERY_STR = 'id, name, type, user_id';
  8. const sql = `select ${QUERY_STR} from type where user_id = 0 or user_id = ${id}`;
  9. try {
  10. const result = await app.mysql.query(sql);
  11. return result;
  12. } catch (error) {
  13. console.log(error);
  14. return null;
  15. }
  16. }
  17. }
  18. module.exports = TypeService;

添加路由

  1. /** 获取消费类型列表 */
  2. router.get('/api/type/list', _jwt, controller.type.list);

账单列表获取

账单列表的获取,我们可以先查看前端需要做成怎样的展示形式:
image.png
分析上图,账单是以时间作为维度,比如我在 2021 年 1 月 1 日记录了 2 条账单,在 2021 年 1 月 2 日记录了 1 条账,单我们返回的数据就是这样的:

  1. [
  2. {
  3. date: '2022-10-10',
  4. bills: [
  5. {
  6. // bill 数据表中的每一项账单
  7. },
  8. {
  9. // bill 数据表中的每一项账单
  10. }
  11. ]
  12. },
  13. {
  14. date: '2022-10-10',
  15. bills: [
  16. {
  17. // bill 数据表中的每一项账单
  18. },
  19. ]
  20. }
  21. ]

并且我们前端还需要做滚动加载更多,所以服务端是需要给分页的。于是就需要在获取 bill 表里的数据之后,进行一系列的操作,将数据整合成上述格式。
当然,获取的时间维度以月为单位,并且可以根据账单类型进行筛选。上图左上角有当月的总支出和总收入情况,我们也在列表数据中给出,因为它和账单数据是强相关的
打开 /controller/bill.js 添加一个 list 方法,来处理账单数据列表:

  1. 'use strict';
  2. const moment = require('moment');
  3. const Controller = require('egg').Controller;
  4. class BillController extends Controller {
  5. async list() {
  6. const { ctx, app } = this;
  7. const { date, page = 1, page_size = 5, type_id = 'all' } = ctx.query;
  8. try {
  9. // 通过 token 解析,拿到 user_id
  10. const token = ctx.request.header.authorization;
  11. const decode = await app.jwt.verify(token, app.config.jwt.secret);
  12. if (!decode) return;
  13. const user_id = decode.id;
  14. // 1.该用户所有的账单数据
  15. const list = await ctx.service.bill.list(id, user_id);
  16. // 2.返回筛选条件下的数据
  17. const _list = list.filter(item => {
  18. if (type_id !== 'all') {
  19. // 根据类型和时间(当月)筛选数据
  20. return moment(Number(item.date)).format('YYYY-MM') === date.toString() && type_id.toString() === item.type_id;
  21. }
  22. // 返回当月所有的数据
  23. return moment(Number(item.date)).format('YYYY-MM') === date;
  24. });
  25. // 3.格式化数据,变成前端需要的格式
  26. const listMap = _list.reduce((curr, item) => {
  27. const date = moment(Number(item.date)).format('YYYY-MM-DD');
  28. // 日期相同的放到同一个数组bills中
  29. if (curr && curr.length && curr.findIndex(item => item.date === date) > -1) {
  30. const index = curr.findIndex(item => item.date === date);
  31. curr[index].bills.push(item);
  32. }
  33. // 如果是不同的日期,新建一个数组
  34. if (curr && curr.length && curr.findIndex(item => item.date === date) === -1) {
  35. curr.push({
  36. date,
  37. bills: [ item ],
  38. });
  39. }
  40. // 如果 curr 为空数组,则默认添加第一个账单项 item
  41. if (!curr.length) {
  42. curr.push({
  43. date,
  44. bills: [ item ],
  45. });
  46. }
  47. return curr;
  48. }, []).sort((a, b) => moment(b.date) - moment(a.date));
  49. // 4.分页处理
  50. const pageListMap = listMap.slice((page - 1) * page_size, page * page_size);
  51. // 5.计算当月总收入和支出
  52. // 总支出
  53. const totalExpense = _list.reduce((curr, item) => {
  54. if (item.pay_type === 1) {
  55. curr += Number(item.amount);
  56. return curr;
  57. }
  58. return curr;
  59. }, 0);
  60. // 总支出
  61. const totalIncome = _list.reduce((curr, item) => {
  62. if (item.pay_type === 2) {
  63. curr += Number(item.amount);
  64. return curr;
  65. }
  66. return curr;
  67. }, 0);
  68. // 6.返回数据
  69. ctx.body = {
  70. coe: 200,
  71. msg: '请求成功',
  72. data: {
  73. totalExpense, // 当月支出
  74. totalIncome, // 当月收入
  75. totalPage: Math.ceil(listMap.length / page_size), // 总分页
  76. list: pageListMap || [], // 格式化后,并且经过分页处理的数据
  77. },
  78. };
  79. } catch (e) {
  80. console.log(e);
  81. ctx.body = {
  82. code: 500,
  83. msg: '系统错误',
  84. data: null,
  85. };
  86. }
  87. }
  88. }
  89. module.exports = BillController;

里面具体逻辑可以查看代码注释,主要的是对数据格式处理的操作比较多一些。
上述代码使用到了 service 服务 ctx.service.bill.list,所以后续我们需要在 /service/bill.js 下新建 list 方法,如下所示:

  1. 'use strict';
  2. const Service = require('egg').Service;
  3. class BillService extends Service {
  4. async list(id) {
  5. const { app } = this;
  6. const QUERY_STR = 'id, pay_type, amount, date, type_id, type_name, remark';
  7. // 从 bill 表中查询 user_id 等于当前用户 id 的账单数据,并且返回的属性是 id, pay_type, amount, date, type_id, type_name, remark
  8. const sql = `select ${QUERY_STR} from bill where user_id = ${id}`;
  9. try {
  10. const result = app.mysql.query(sql);
  11. return result;
  12. } catch (e) {
  13. console.log(e);
  14. return null;
  15. }
  16. }
  17. }
  18. module.exports = BillService;

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

  1. /** 账单列表 */
  2. router.get('/api/bill/list', _jwt, controller.bill.list);

账单详情接口

我们继续制作账单修改接口,修改接口和新增接口的区别在于,新增是在没有的情况下,编辑好参数,添加进数据库内部。而修改接口则是编辑现有的数据,根据当前账单的 id,更新数据。
所以这里我们需要实现两个接口:
1、获取账单详情接口
2、更新数据接口
我们先来完成获取账单详情接口,在 /controller/bill.js 添加 detail 方法,代码如下所示:

  1. 'use strict';
  2. const Controller = require('egg').Controller;
  3. class BillController extends Controller {
  4. async detail() {
  5. const { ctx, app } = this;
  6. const { id = '' } = ctx.query;
  7. const token = ctx.request.header.authorization;
  8. // 获取当前用户信息
  9. const decode = await app.jwt.verify(token, app.config.jwt.secret);
  10. if (!decode) return;
  11. const user_id = decode.id;
  12. if (!id) {
  13. ctx.body = {
  14. code: 500,
  15. msg: 'id不存在',
  16. data: null,
  17. };
  18. return;
  19. }
  20. try {
  21. // 从数据库中获取账单详情
  22. const result = await ctx.service.bill.detail(user_id);
  23. ctx.body = {
  24. code: 200,
  25. msg: '请求成功',
  26. data: result,
  27. };
  28. } catch {
  29. ctx.body = {
  30. code: 500,
  31. msg: '系统错误',
  32. data: null,
  33. };
  34. }
  35. }
  36. }
  37. module.exports = BillController;

在/service/bill.js 添加 ctx.service.bill.detail 方法,如下所示:

  1. 'use strict';
  2. const Service = require('egg').Service;
  3. class BillService extends Service {
  4. async detail(id) {
  5. const { app } = this;
  6. try {
  7. const result = await app.mysql.get('bill', { id });
  8. return result;
  9. } catch (e) {
  10. console.log(e);
  11. return null;
  12. }
  13. }
  14. }
  15. module.exports = BillService;

添加路由

  1. /** 账单详情 */
  2. router.get('/api/bill/detail', _jwt, controller.bill.detail);

编辑账单接口

在 /controller/bill.js 添加 update 方法,如下所示:

  1. 'use strict';
  2. const moment = require('moment');
  3. const Controller = require('egg').Controller;
  4. class BillController extends Controller {
  5. async update() {
  6. const { ctx, app } = this;
  7. // 账单的相关参数,这里注意要把账单的 id 也传进来
  8. const { id, amount, type_id, type_name, date, pay_type, remark = '' } = ctx.request.body;
  9. const token = ctx.request.header.authorization;
  10. // 获取当前用户信息
  11. const decode = await app.jwt.verify(token, app.config.jwt.secret);
  12. if (!decode) return;
  13. const user_id = decode.id;
  14. // 判空处理
  15. if (!amount || !type_id || !type_name || !date || !pay_type) {
  16. ctx.body = {
  17. code: 400,
  18. msg: '参数错误',
  19. data: null,
  20. };
  21. }
  22. try {
  23. // 根据账单 id 和 user_id,修改账单数据
  24. await ctx.service.bill.update({
  25. id, // 账单 id
  26. amount, // 金额
  27. type_id, // 消费类型 id
  28. type_name, // 消费类型名称
  29. date, // 日期
  30. pay_type, // 消费类型
  31. remark, // 备注
  32. user_id, // 用户 id
  33. });
  34. ctx.body = {
  35. code: 200,
  36. msg: '请求成功',
  37. data: null,
  38. };
  39. } catch {
  40. ctx.body = {
  41. code: 500,
  42. msg: '系统错误',
  43. data: null,
  44. };
  45. }
  46. }
  47. }
  48. module.exports = BillController;

ctx.service.bill.update 便是操作数据库修改当前账单 id 的方法,我们需要在 /service/bill.js 添加相应的方法,如下所示:

  1. 'use strict';
  2. const Service = require('egg').Service;
  3. class BillService extends Service {
  4. async update(params) {
  5. const { app } = this;
  6. try {
  7. const result = await app.mysql.update('bill', {
  8. ...params,
  9. },
  10. {
  11. id: params.id,
  12. user_id: params.user_id,
  13. });
  14. return result;
  15. } catch (e) {
  16. console.log(e);
  17. return null;
  18. }
  19. }
  20. }
  21. module.exports = BillService;

添加路由

  1. /** 修改账单 */
  2. router.post('/api/bill/update', _jwt, controller.bill.update);

账单删除接口

账单删除只需要获取到单笔账单的 id,通过 id 去删除数据库中对应的账单数据。我们打开 /controller/bill.js 添加 delete 方法,如下所示:

  1. 'use strict';
  2. const moment = require('moment');
  3. const Controller = require('egg').Controller;
  4. class BillController extends Controller {
  5. async delete() {
  6. const { ctx, app } = this;
  7. // 账单的相关参数,这里注意要把账单的 id 也传进来
  8. const { id } = ctx.request.body;
  9. const token = ctx.request.header.authorization;
  10. // 获取当前用户信息
  11. const decode = await app.jwt.verify(token, app.config.jwt.secret);
  12. if (!decode) return;
  13. const user_id = decode.id;
  14. if (!id) {
  15. ctx.body = {
  16. code: 400,
  17. msg: '参数错误',
  18. data: null,
  19. };
  20. }
  21. try {
  22. await ctx.service.bill.delete(id, user_id);
  23. ctx.body = {
  24. code: 200,
  25. msg: '请求成功',
  26. data: null,
  27. };
  28. } catch {
  29. ctx.body = {
  30. code: 500,
  31. msg: '系统错误',
  32. data: null,
  33. };
  34. }
  35. }
  36. }
  37. module.exports = BillController;

并且前往 /service/bill.js 添加 delete 服务,如下所示:

  1. 'use strict';
  2. const Service = require('egg').Service;
  3. class BillService extends Service {
  4. async delete(id, user_id) {
  5. const { app } = this;
  6. try {
  7. const result = await app.mysql.delete('bill', {
  8. id,
  9. user_id,
  10. });
  11. return result;
  12. } catch (e) {
  13. console.log(e);
  14. return null;
  15. }
  16. }
  17. }
  18. module.exports = BillService;

最后添加路由

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

数据图表接口

我们在实现接口之前,先看看需要实现的需求:
image.pngimage.png
首先是头部的汇总数据,并且接口支持事件筛选,以 月 为单位。
其次是收支的构成图,对每一个类型的支出和收入进行累加,最后通过计算占比以此从大到小排布。如上图所示,当前月份的所有支出和收入,这个累加计算,我们在服务端完成。
最后我们引入 echarts ,完成一个饼图的简单排布,其实也就是上图收支比例图的一个变种。
我们最终要返回的数据机构如下:

  1. {
  2. total_data: [
  3. {
  4. number: 137.84, // 支出或收入数量
  5. pay_type: 1, // 支出或消费类型值
  6. type_id: 1, // 消费类型id
  7. type_name: "餐饮" // 消费类型名称
  8. }
  9. ],
  10. total_expense: 3123.54, // 总消费
  11. total_income: 6555.80 // 总收入
  12. }

数据接口实现
经过上述分析,那我们就开始实现一下,我们将方法写在 /controller/bill.js 中,添加 data 方法,如下所示:

  1. 'use strict';
  2. const moment = require('moment');
  3. const Controller = require('egg').Controller;
  4. class BillController extends Controller {
  5. async data() {
  6. const { ctx, app } = this;
  7. // 账单的相关参数,这里注意要把账单的 id 也传进来
  8. const { date = '' } = ctx.query;
  9. const token = ctx.request.header.authorization;
  10. // 获取当前用户信息
  11. const decode = await app.jwt.verify(token, app.config.jwt.secret);
  12. if (!decode) return;
  13. const user_id = decode.id;
  14. if (!date) {
  15. ctx.body = {
  16. code: 400,
  17. msg: '参数错误',
  18. data: null,
  19. };
  20. return;
  21. }
  22. try {
  23. // 获取账单表中的账单数据
  24. const result = await ctx.service.bill.list(user_id);
  25. // 根据时间参数,筛选出当月所有的账单数据
  26. const start = moment(date).startOf('month').unix() * 1000; // 选择月份,月初时间
  27. const end = moment(date).endOf('month').unix() * 1000; // 选择月份,月末时间
  28. const _data = result.filter(item => (Number(item.date) > start && Number(item.date) < end));
  29. // 总支出
  30. const total_expense = _data.reduce((arr, cur) => {
  31. if (cur.pay_type === 1) {
  32. arr += Number(cur.amount);
  33. }
  34. return arr;
  35. }, 0);
  36. // 总收入
  37. const total_income = _data.reduce((arr, cur) => {
  38. if (cur.pay_type === 2) {
  39. arr += Number(cur.amount);
  40. }
  41. return arr;
  42. }, 0);
  43. // 获取收支构成
  44. let total_data = _data.reduce((arr, cur) => {
  45. const index = arr.findIndex(item => item.type_id === cur.type_id);
  46. if (index === -1) {
  47. arr.push({
  48. type_id: cur.type_id,
  49. type_name: cur.type_name,
  50. pay_type: cur.pay_type,
  51. number: Number(cur.amount),
  52. });
  53. }
  54. if (index > -1) {
  55. arr[index].number += Number(cur.amount);
  56. }
  57. return arr;
  58. }, []);
  59. total_data = total_data.map(item => {
  60. item.number = Number(Number(item.number).toFixed(2));
  61. return item;
  62. });
  63. ctx.body = {
  64. code: 200,
  65. msg: '请求成功',
  66. data: {
  67. total_expense: Number(total_expense).toFixed(2),
  68. total_income: Number(total_income).toFixed(2),
  69. total_data: total_data || [],
  70. },
  71. };
  72. } catch {
  73. ctx.body = {
  74. code: 500,
  75. msg: '系统错误',
  76. data: null,
  77. };
  78. }
  79. }
  80. }
  81. module.exports = BillController;

相关逻辑可以看注释,还是对一些数据格式的处理,搞清需要什么样的数据格式,就自然而然会了。
最后,添加一下路由:

  1. /** 统计数据 */
  2. router.get('/api/bill/data', _jwt, controller.bill.data);