基于Vue2和Node.js的反欺诈系统设计与实现

Posted SHERlocked93

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了基于Vue2和Node.js的反欺诈系统设计与实现相关的知识,希望对你有一定的参考价值。

最近包工头喊农民工小郑搬砖,小郑搬完砖后沉思片刻,决定写篇小作文分享下,作为一个初学者的全栈项目,去学习它的搭建,到落地,再到部署维护,是非常好的。

------题记

写在前面

通过本文的学习,你可以学到

  • vue2、element ui、vue-element-admin在前端的使用

  • 组件设计

  • echarts在前端中的使用

  • eggjs在后端node项目中的使用

  • docker一键化部署

需求分析

背景

近些年,网络诈骗案频发,有假扮家里茶叶滞销的茶花女,有假扮和男朋友分手去山区支教的女教师,有告知你中了非常6+1的大奖主持人,有假扮越南那边过来结婚的妹子,各类案件层出不穷。作为公民,我们应该在社会主义新时代下积极学习组织上宣传反诈骗知识,提高防范意识。除此之外,对于种种诈骗案件,是网站的我们就应该封其网站,是电话的我们就应该封其电话,是银行的我们就该封其银行账号,是虚拟账号的我们就应该封其虚拟账号。我相信,在我们的不懈努力之下,我们的社会将会更和谐更美好!

需求

长话短说,需求大致是这样子的:有管理员、市局接警员、县区局接警员、电话追查专员、网站追查专员、银行追查专员、虚拟账号专员这几类角色, 相关的角色可以进入相关的页面进行相关的操作,其中市局和管理员的警情录入是不需要审核,直接派单下去,而县区局的警情录入需要进行审核。当审核通过后,会进行相应的派单。各类追查员将结果反馈给该警单。系统管理员这边还可以进行人员、机构、警情类别,银行卡、数据统计、导出等功能。希望是越快越好,越简单越好,领导要看的。

部分效果如图:

技术预研

这个项目不是很大,复杂度也不是很高,并发量也不会太大,毕竟是部署在public police network下的。所以我这边选用vue2,结合花裤衩大佬的vue-element-admin,前端这边就差不多了,后端这边用的是阿里开源的eggjs,因为它使用起来很方便。数据库用的是mysql。部署这边提供了两套方案,一套是传统的nginx、mysql、node、一个一个单独安装配置。另一种是docker部署的方式。

功能实现

前端

vue代码规范

参见:https://www.yuque.com/ng46ql/tynary

vue工程目录结构

参见:https://panjiachen.gitee.io/vue-element-admin-site/zh/guide/#%E7%9B%AE%E5%BD%95%E7%BB%93%E6%9E%84

vue组件设计与封装

这里我选了几个有代表性的典型的组件来讲解,我们先来看一张图找找组件设计和封装的感觉。

通过观察我们发现,在后台管理界面中,蛮多的页面是长这样子的,我们不可能来一个页面我们就再写一次布局,这样人都要搞没掉。所以我们会有想法地把它封装成一个container.vue,它主要包含头部的标题和右边的新增按钮、中间的过滤面板以及下方的表格。

container.vue是一个布局组件,它主要是框定了你一个页面大致的布局, 在适当的位置,我们加入插槽slot去表示这块未知的区域,container.vue代码实现如下:

<template>
  <div>
    <el-row class="top">
      <el-col :span="24">
        <el-row>
          <el-col :span="12">
            <div
              v-if="title"
              class="title"
            >
              {{ title }}
            </div>
          </el-col>
          <el-col
            :span="12"
            class="btn-group"
          >
            <slot name="topExtra" />
            <el-col />
          </el-col>
        </el-row>
      </el-col>
      <el-col :span="24">
        <slot name="tab" />
      </el-col>
    </el-row>
    <div class="content">
      <slot name="content" />
    </div>
  </div>
</template>

<script>
export default {
  name: 'CommonContainer',
  props: {
    title: { type: String, default: '' }
  }
}
</script>

<style lang="scss" scoped>
.top {
  padding: 15px;
  min-height: 100px;
  background-color: #fff;
  box-shadow: 0 3px 5px -3px rgba(0, 0, 0, 0.1);
}

.title-box {
  height: 100px;
  line-height: 100px;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.title {
  font-size: 30px;
  font-weight: 700;
}
.content {
  margin: 20px 5px 0;
  padding: 20px 10px;
  background: #fff;
}

.btn-group {
  text-align: right;
  padding: 0 10px;
}
</style>

往下走,我们会想到怎么去设计表格这个组件,在设计这个组件的时候,我们需要清楚的知道,这个组件的输入以及输出是什么?比如说table-query.vue这个组件,从名字我们能够看出,它是有查询请求的,那么对于请求,很容易抽象出的一些东西是,请求地址,请求参数,请求方法等等,所以这边的props大致可以这么敲定。

props: {
    // 请求表格数据的url地址
    url: { type: String, required: true },
    // 默认分页数
    pageSize: { type: Number, default: 10 },
    // 是否展示序号
    index: { type: Boolean, default: true },
    // 表格的列的结构
    columns: { type: Array, required: true },
    orgId: { type: String, required: false, default: '' },
    // 请求表格数据的方法
    method: { type: String, default: 'post' },
    // 请求表格数据的参数
    params: { type: Object, default: () => ({}) },
    // 是否支持高亮选中
    isHighlightRow: { type: Boolean, default: false },
    // 是否显示分页
    isShowPagination: { type: Boolean, default: true },
    // 是否显示迷你分页
    isPaginationSizeSmall: { type: Boolean, default: false }
  },

这里的输出,我们期望的是,当用户点击详情、查看、删除的时候,我要知道这一行的具体数据,那么大致可以这么敲定。

handleClick(row, type, title) {
    this.$emit('click-action', row, type, title)
 },

这边作为组件的数据通信已经敲定了,剩下的也就是一些封装请求的逻辑,页面交互的逻辑,具体地可以看一下table-query.vue的实现

<template>
  <div>
    <el-table
      ref="table"
      border
      :data="data"
      :loading="isLoading"
      :highlight-row="isHighlightRow"
      :row-class-name="tableRowClassName"
    >
      <template v-for="column in columns">
        <template v-if="column.key === 'actions'">
          <el-table-column
            :key="column.key"
            align="center"
            :width="column.width"
            :label="column.title"
          >
            <template slot-scope="scope">
              <el-button
                v-for="action in filterOperate(
                  column.actions,
                  scope.row.btnList
                )"
                :key="action.type"
                type="text"
                size="small"
                @click="handleClick(scope.row, action.type, action.title)"
              >{{ action.title }}</el-button>
            </template>
          </el-table-column>
        </template>
        <template v-else-if="column.key === 'NO'">
          <el-table-column
            :key="column.key"
            type="index"
            width="80"
            align="center"
            :label="column.title"
          />
        </template>
        <template v-else>
          <el-table-column
            :key="column.key"
            align="center"
            :prop="column.key"
            :width="column.width"
            :label="column.title"
            :formatter="column.formatter"
            :show-overflow-tooltip="true"
          />
        </template>
      </template>
    </el-table>
    <el-row type="flex" justify="center" style="margin-top: 10px;">
      <el-col :span="24">
        <el-pagination
          v-if="isShowPagination"
          :small="true"
          :total="total"
          :background="true"
          :page-sizes="pageSizeOptions"
          :current-page="pagination.page"
          :page-size="pagination.pageSize"
          @current-change="changePage"
          @size-change="changePageSize"
        />
      </el-col>
    </el-row>
  </div>
</template>

<script>
import request from '@/utils/request'
import { getLength } from '@/utils/tools'

export default {
  name: 'CommonTableQuery',
  props: {
    // 请求表格数据的url地址
    url: { type: String, required: true },
    // 默认分页数
    pageSize: { type: Number, default: 10 },
    // 是否展示序号
    index: { type: Boolean, default: true },
    // 表格的列的结构
    columns: { type: Array, required: true },
    orgId: { type: String, required: false, default: '' },
    // 请求表格数据的方法
    method: { type: String, default: 'post' },
    // 请求表格数据的参数
    params: { type: Object, default: () => ({}) },
    // 是否支持高亮选中
    isHighlightRow: { type: Boolean, default: false },
    // 是否显示分页
    isShowPagination: { type: Boolean, default: true },
    // 是否显示迷你分页
    isPaginationSizeSmall: { type: Boolean, default: false }
  },
  data() {
    return {
      // 表格的行
      data: [],
      // 分页总数
      total: 0,
      // 表格数据是否加载
      isLoading: false,
      // 是否全选
      isSelectAll: false,
      // 渲染后的列数据字段
      renderColumns: [],
      // 分页
      pagination: {
        page: 1,
        pageSize: this.pageSize
      }
    }
  },
  computed: {
    // 是否有数据
    hasData() {
      return getLength(this.data) > 0
    },
    // 分页条数
    pageSizeOptions() {
      return this.isPaginationSizeSmall ? [10, 20, 30] : [10, 20, 30, 50, 100]
    }
  },
  created() {
    this.getTableData()
  },
  methods: {
    tableRowClassName({ row, rowIndex }) {
      // if (rowIndex === 1) {
      //   return 'warning-row'
      // } else if (rowIndex === 3) {
      //   return 'success-row'
      // }
      if (row.alarmNo && row.alarmNo.startsWith('FZYG')) {
        return 'warning-row'
      }
      return ''
    },
    // 改变分页
    changePage(page) {
      this.pagination.page = page
      this.getTableData()
    },
    // 改变分页大小
    changePageSize(pageSize) {
      this.pagination.pageSize = pageSize
      this.getTableData()
    },
    // 获取表格的数据
    getTableData() {
      if (!this.url) {
        return
      }
      const {
        url,
        params,
        orgId,
        pagination: { page, pageSize },
        isShowPagination,
        method
      } = this
      this.isLoading = true
      this.isSelectAll = false
      const parameter = isShowPagination
        ? { page, pageSize, orgId, ...params }
        : { orgId, ...params }
      request({
        method,
        url,
        [method === 'post' ? 'data' : 'params']: parameter
      })
        .then(res => {
          const {
            data: { list = [], total, page, pageSize }
          } = res || {}
          this.isLoading = false
          this.data = list
          if (this.isShowPagination) {
            this.total = total === null ? 0 : total
            this.pagination = {
              page,
              pageSize
            }
          }
        })
        .catch(err => {
          this.isLoading = false
          console.log(err)
        })
    },
    // 手动挡分页查询
    query(page = 1, pageSize = 10) {
      this.pagination = { page, pageSize }
      this.getTableData()
    },
    handleClick(row, type, title) {
      this.$emit('click-action', row, type, title)
    },
    filterOperate(actions, btnList) {
      return actions.filter(action => btnList.includes(action.type))
    }
  }
}
</script>

<style>
.el-table .warning-row {
  background: oldlace;
}
.el-table .success-row {
  background: #f0f9eb;
}
.el-tooltip__popper {
  max-width: 80%;
}
.el-tooltip__popper,
.el-tooltip__popper.is-dark {
  background: #f5f5f5 !important;
  color: #303133 !important;
}
</style>

element-table: https://element.eleme.cn/#/zh-CN/component/table

element-pagination: https://element.eleme.cn/#/zh-CN/component/pagination

文件上传与下载,这个是点开警情、追查的相关页面进去的功能,大体上和楼上的表格类似,就是在原来的基础上,去掉了分页,加上了文件上传的组件。


“DO NOT REPEAT"原则, 我们期望的是写一次核心代码就好,剩下的我们每次只需要在用到的地方引入table-file.vue 就好了,这样子维护起来也方便,这就有个这个组件的想法。

我们还是想一下,对于文件我们不外乎有这些操作,上传、下载、删除、修改、预览等等,所以这边组件的输入大致可以这么敲定。

props: {
    canUpload: { type: Boolean, default: true },
    canDelete: { type: Boolean, default: true },
    canDownload: { type: Boolean, default: true },
    columns: { type: Array, default: () => [] },
    affix: { type: String, default: '' }
  },

输出的话,跟楼上的table-query.vue差不多

handleClick(row, type, title) {
      this.$emit('click-action', row, type, title)
 },

具体地可以看下table-file.vue 的实现

<template>
  <el-row>
    <el-col v-if="canUpload" :span="24">
      <el-upload
        ref="upload"
        :action="url"
        drag
        :limit="9"
        name="affix"
        :multiple="true"
        :auto-upload="false"
        :with-credentials="true"
        :on-error="onError"
        :file-list="fileList"
        :on-remove="onRemove"
        :on-change="onChange"
        :on-exceed="onExceed"
        :on-success="onSuccess"
        :on-preview="onPreview"
        :before-upload="beforeUpload"
        :before-remove="beforeRemove"
        :on-progress="onProgress"
        :headers="headers"
      >
        <!-- <el-button size="small" type="primary">选择文件</el-button> -->
        <i class="el-icon-upload" />
        <div class="el-upload__text">将文件拖到此处,或<em>选择文件</em></div>
        <div slot="tip" class="el-upload__tip">
          文件格式不限,一次最多只能上传9个文件,单个文件允许最大100MB
        </div>
      </el-upload>
    </el-col>
    <el-col v-if="canUpload" style="margin: 10px auto;">
      <el-button
        size="small"
        type="primary"
        @click="upload"
      >确认上传</el-button>
    </el-col>
    <el-col :span="24">
      <el-table
        ref="table"
        border
        :data="data"
        style="width: 100%; margin: 20px auto;"
      >
        <template v-for="column in mapColumns">
          <template v-if="column.key === 'actions'">
            <el-table-column
              :key="column.key"
              align="center"
              :label="column.title"
            >
              <template slot-scope="scope">
                <el-button
                  v-for="action in column.actions"
                  :key="action.type"
                  type="text"
                  size="small"
                  @click="handleClick(scope.row, action.type, action.title)"
                >{{ action.title }}</el-button>
              </template>
            </el-table-column>
          </template>
          <template v-else-if="column.key === 'NO'">
            <el-table-column
              :key="column.key"
              type="index"
              width="80"
              align="center"
              :label="column.title"
            />
          </template>
          <template v-else>
            <el-table-column
              :key="column.key"
              :prop="column.key"
              align="center"
              :label="column.title"
            />
          </template>
        </template>
      </el-table>
    </el-col>
  </el-row>
</template>

<script>
import Cookies from 'js-cookie'
import { getByIds } from '@/api/file'
import { formatDate } from '@/utils/tools'

export default {
  name: 'TableFile',
  props: {
    canUpload: { type: Boolean, default: true },
    canDelete: { type: Boolean, default: true },
    canDownload: { type: Boolean, default: true },
    columns: { type: Array, default: () => [] },
    affix: { type: String, default: '' }
  },
  data() {
    return {
      fileList: [],
      data: [],
      ids: [],
      headers: {
        'x-csrf-token': Cookies.get('csrfToken')
      },
      mapColumns: [],
      url: process.env.VUE_APP_UPLOAD_API
    }
  },
  watch: {
    affix: {
      async handler(newAffix) {
        this.data = []
        this.ids = []
        if (newAffix) {
          this.ids = newAffix.split(',').map(id => Number(id))
          if (this.ids.length > 0) {
            const { data } = await getByIds({ ids: this.ids })
            this.data = data.map(item => {
              const { createTime, ...rest } = item
              return {
                createTime: formatDate(
                  'YYYY-MM-DD HH:mm:ss',
                  createTime * 1000
                ),
                ...rest
              }
            })
          }
        }
      },
      immediate: true
    },
    canDelete: {
      handler(newVal) {
        if (newVal) {
          this.mapColumns = JSON.parse(JSON.stringify(this.columns))
        } else {
          if (this.mapColumns[this.mapColumns.length - 1]) {
            this.mapColumns[this.mapColumns.length - 1].actions = [
              {
                title: '下载',
                type: 'download'
              }
            ]
          }
        }
      },
      immediate: true
    }
  },
  created() {
    this.mapColumns = JSON.parse(JSON.stringify(this.columns))
    if (!this.canDelete) {
      if (this.mapColumns[this.mapColumns.length - 1]) {
        this.mapColumns[this.mapColumns.length - 1].actions = [
          {
            title: '下载',
            type: 'download'
          }
        ]
      }
    }
  },
  methods: {
    beforeUpload(file, fileList) {
      console.log('beforeUpload: ', file, fileList)
    },
    onSuccess(response, file, fileList) {
      const {
        data: { id, createTime, ...rest }
      } = response
      this.data.push({
        id,
        createTime: formatDate('YYYY-MM-DD HH:mm:ss', createTime * 1000),
        ...rest
      })
      this.ids.push(id)
      this.clear()
    },
    onError(err, file, fileList) {
      console.log(err, file, fileList)
    },
    onPreview(file, fileList) {
      console.log('onPreview: ', file, fileList)
    },
    beforeRemove(file, fileList) {
      console.log('beforeRemove: ', file, fileList)
    },
    onExceed(files, fileList) {
      console.log('onExceed: ', files, fileList)
      // this.$message.warning(`当前限制选择 3 个文件,本次选择了 ${files.length} 个文件,共选择了 ${files.length + fileList.length} 个文件`)
    },
    onRemove(file, fileList) {
      console.log('onRemove: ', file, fileList)
    },
    onChange(file, fileList) {
      console.log('onChange: ', file, fileList)
    },
    onProgress(file, fileList) {
      console.log('onProgress: ', file, fileList)
    },
    upload() {
      this.$refs.upload.submit()
    },
    clear() {
      this.$refs.upload.clearFiles()
      this.fileList = []
    },
    handleClick(row, type, title) {
      this.$emit('click-action', row, type, title)
    },
    deleteData(id) {
      const index = this.ids.indexOf(id)
      this.ids.splice(index, 1)
      this.data.splice(index, 1)
    }
  }
}
</script>

<style scoped>
.center {
  display: flex;
  justify-content: center;
}
</style>

element-upload: https://element.eleme.cn/#/zh-CN/component/upload

功能实现-文件导出

数据的导出也是这种后台管理系统比较常见的场景,这件事情可以前端做,也可以后端做。那么在这里结合xlsxfile-saver这两个包,在src下新建一个excel文件夹, 然后新建一个js文件export2Excel.js

/* eslint-disable */
import { saveAs } from 'file-saver'
import XLSX from 'xlsx'

function generateArray(table) {
  var out = [];
  var rows = table.querySelectorAll('tr');
  var ranges = [];
  for (var R = 0; R < rows.length; ++R) {
    var outRow = [];
    var row = rows[R];
    var columns = row.querySelectorAll('td');
    for (var C = 0; C < columns.length; ++C) {
      var cell = columns[C];
      var colspan = cell.getAttribute('colspan');
      var rowspan = cell.getAttribute('rowspan');
      var cellValue = cell.innerText;
      if (cellValue !== "" && cellValue == +cellValue) cellValue = +cellValue;

      //Skip ranges
      ranges.forEach(function (range) {
        if (R >= range.s.r && R <= range.e.r && outRow.length >= range.s.c && outRow.length <= range.e.c) {
          for (var i = 0; i <= range.e.c - range.s.c; ++i) outRow.push(null);
        }
      });

      //Handle Row Span
      if (rowspan || colspan) {
        rowspan = rowspan || 1;
        colspan = colspan || 1;
        ranges.push({
          s: {
            r: R,
            c: outRow.length
          },
          e: {
            r: R + rowspan - 1,
            c: outRow.length + colspan - 1
          }
        });
      };

      //Handle Value
      outRow.push(cellValue !== "" ? cellValue : null);

      //Handle Colspan
      if (colspan)
        for (var k = 0; k < colspan - 1; ++k) outRow.push(null);
    }
    out.push(outRow);
  }
  return [out, ranges];
};

function datenum(v, date1904) {
  if (date1904) v += 1462;
  var epoch = Date.parse(v);
  return (epoch - new Date(Date.UTC(1899, 11, 30))) / (24 * 60 * 60 * 1000);
}

function sheet_from_array_of_arrays(data, opts) {
  var ws = {};
  var range = {
    s: {
      c: 10000000,
      r: 10000000
    },
    e: {
      c: 0,
      r: 0
    }
  };
  for (var R = 0; R != data.length; ++R) {
    for (var C = 0; C != data[R].length; ++C) {
      if (range.s.r > R) range.s.r = R;
      if (range.s.c > C) range.s.c = C;
      if (range.e.r < R) range.e.r = R;
      if (range.e.c < C) range.e.c = C;
      var cell = {
        v: data[R][C]
      };
      if (cell.v == null) continue;
      var cell_ref = XLSX.utils.encode_cell({
        c: C,
        r: R
      });

      if (typeof cell.v === 'number') cell.t = 'n';
      else if (typeof cell.v === 'boolean') cell.t = 'b';
      else if (cell.v instanceof Date) {
        cell.t = 'n';
        cell.z = XLSX.SSF._table[14];
        cell.v = datenum(cell.v);
      } else cell.t = 's';

      ws[cell_ref] = cell;
    }
  }
  if (range.s.c < 10000000) ws['!ref'] = XLSX.utils.encode_range(range);
  return ws;
}

function Workbook() {
  if (!(this instanceof Workbook)) return new Workbook();
  this.SheetNames = [];
  this.Sheets = {};
}

function s2ab(s) {
  var buf = new ArrayBuffer(s.length);
  var view = new Uint8Array(buf);
  for (var i = 0; i != s.length; ++i) view[i] = s.charCodeAt(i) & 0xFF;
  return buf;
}

export function export_table_to_excel(id) {
  var theTable = document.getElementById(id);
  var oo = generateArray(theTable);
  var ranges = oo[1];

  /* original data */
  var data = oo[0];
  var ws_name = "SheetJS";

  var wb = new Workbook(),
    ws = sheet_from_array_of_arrays(data);

  /* add ranges to worksheet */
  // ws['!cols'] = ['apple', 'banan'];
  ws['!merges'] = ranges;

  /* add worksheet to workbook */
  wb.SheetNames.push(ws_name);
  wb.Sheets[ws_name] = ws;

  var wbout = XLSX.write(wb, {
    bookType: 'xlsx',
    bookSST: false,
    type: 'binary'
  });

  saveAs(new Blob([s2ab(wbout)], {
    type: "application/octet-stream"
  }), "test.xlsx")
}

export function export_json_to_excel({
  multiHeader = [],
  header,
  data,
  filename,
  merges = [],
  autoWidth = true,
  bookType = 'xlsx'
} = {}) {
  /* original data */
  filename = filename || 'excel-list'
  data = [...data]
  data.unshift(header);

  for (let i = multiHeader.length - 1; i > -1; i--) {
    data.unshift(multiHeader[i])
  }

  var ws_name = "SheetJS";
  var wb = new Workbook(),
    ws = sheet_from_array_of_arrays(data);

  if (merges.length > 0) {
    if (!ws['!merges']) ws['!merges'] = [];
    merges.forEach(item => {
      ws['!merges'].push(XLSX.utils.decode_range(item))
    })
  }

  if (autoWidth) {
    /*设置worksheet每列的最大宽度*/
    const colWidth = data.map(row => row.map(val => {
      /*先判断是否为null/undefined*/
      if (val == null) {
        return {
          'wch': 10
        };
      }
      /*再判断是否为中文*/
      else if (val.toString().charCodeAt(0) > 255) {
        return {
          'wch': val.toString().length * 2
        };
      } else {
        return {
          'wch': val.toString().length
        };
      }
    }))
    /*以第一行为初始值*/
    let result = colWidth[0];
    for (let i = 1; i < colWidth.length; i++) {
      for (let j = 0; j < colWidth[i].length; j++) {
        if (result[j]['wch'] < colWidth[i][j]['wch']) {
          result[j]['wch'] = colWidth[i][j]['wch'];
        }
      }
    }
    ws['!cols'] = result;
  }

  /* add worksheet to workbook */
  wb.SheetNames.push(ws_name);
  wb.Sheets[ws_name] = ws;

  var wbout = XLSX.write(wb, {
    bookType: bookType,
    bookSST: false,
    type: 'binary'
  });
  saveAs(new Blob([s2ab(wbout)], {
    type: "application/octet-stream"
  }), `${filename}.${bookType}`);
}

逻辑代码如下

downloadExcel() {
      this.$confirm('将导出为excel文件,确认导出?', '提示', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning'
      })
        .then(() => {
          this.export2Excel()
        })
        .catch((e) => {
          this.$Message.error(e);
      })
    },
    // 数据写入excel
    export2Excel() {
      import('@/excel/export2Excel').then(excel => {
        const tHeader = [
          '警情编号',
          '警情性质',
          '受害人姓名',
          '受害人账号',
          '嫌疑人账号',
          '嫌疑人电话',
          '涉案总金额',
          '案发时间',
          '警情状态'
        ] // 导出的excel的表头字段
        const filterVal = [
          'alarmNo',
          'alarmProp',
          'informantName',
          'informantBankAccount',
          'suspectsAccount',
          'suspectsMobile',
          'fraudAmount',
          'crimeTime',
          'alarmStatus'
        ] // 对象属性,对应于tHeader
        const list = this.$refs.inputTable.data
        const data = this.formatJson(filterVal, list)
        excel.export_json_to_excel({
          header: tHeader,
          data,
          filename: this.filename,
          autoWidth: this.autoWidth,
          bookType: this.bookType
        })
        this.downloadLoading = false
      })
    },
    // 格式转换,直接复制即可
    formatJson(filterVal, jsonData) {
      return jsonData.map(v =>
        filterVal.map(j => {
          if (j === 'crimeTime') {
            return formatDate('YYYY-MM-DD HH:mm:ss', v[j] * 1000)
          } else if (j === 'alarmProp') {
            return this.alarmPropOptionsArr[v[j]]
          } else if (j === 'alarmStatus') {
            return this.alarmStatusOptionsArr[v[j]]
          } else {
            return v[j]
          }
        })
      )
    }

参见:https://panjiachen.gitee.io/vue-element-admin-site/zh/feature/component/excel.html

功能实现-数据统计与展示

单纯的数据只有存储的价值,而对存储下来的数据进行相应的分析,并加以图表的形式输出,可以更直观地看到数据的变化,体现数据的价值,实现新生代农民工的劳动价值。这边结合echarts对某一个时间段的警情中各部分追查的占比进行了一个统计,除此之外,对该时间段的每月的止付金额进行了一个统计,最终结合扇形和柱形对其进行展示。

翻一翻npm包,笔者物色到了两位包包可以做这件事,考虑到针对本项目对于图表的需求量不是特别大,我也懒得看两套API,就还是用了echarts。

vue-echarts:  https://www.npmjs.com/package/vue-echarts

echarts:  https://www.npmjs.com/package/echarts

我们会有一个数据接口,前端带上相关的请求参数通过请求/prod-api/statistics/calculate这个接口就能够拿到后端的从数据库处理出来的相关数据,这里因为前后端都是我写的,所以我制定的规则就是,所有的计算都有后端去完成,前端只负责展示,并且约定了相关的参数格式。这样做的一个好处是,省去了前端这边对数据的封装处理。返回的格式如下:

{
    "status": 200,
    "message": "success",
    "data": {
        "pieData": [
            {
                "name": "银行查控",
                "count": 13
            },
            {
                "name": "电话查控",
                "count": 10
            },
            {
                "name": "虚拟账号查控",
                "count": 3
            },
            {
                "name": "网站查控",
                "count": 5
            }
        ],
        "barData": [
            {
                "name": "2021年1月",
                "amount": 0
            },
            {
                "name": "2021年2月",
                "amount": 0
            },
            {
                "name": "2021年3月",
                "amount": 0
            },
            {
                "name": "2021年4月",
                "amount": 0
            },
            {
                "name": "2021年5月",
                "amount": 0
            },
            {
                "name": "2021年6月",
                "amount": 0
            },
            {
                "name": "2021年7月",
                "amount": 0
            },
            {
                "name": "2021年8月",
                "amount": 1311601
            }
        ],
        "totalAmount": 1311601
    }
}

这里以画饼图和柱形图为例,其他的也是类似的,可以参考https://echarts.apache.org/examples/zh/index.html

公共部分

npm i echarts -S安装echarts的npm包,然后在相应的文件引入它。

import echarts from 'echarts'
画饼图

在template中我们搞一个饼图的div

<div ref="pieChart" class="chart" />

在vue的方法里面,我们定义一个画饼的方法,这里定义的输入就是请求后端返回的数据,其他的看echarts的配置项,这边都配好了(如果写成单个组件,需要根据业务考虑相关的配置项,目前这边就care数据项)。逻辑是这样子的,定义了一个基于数据项变动的配置项options,然后当执行drawPie方法的时候,如果没有初始化echarts,那么我们这边就初始化一个echarts的饼,如果有,那么我们就只有更新相关的options就好了。

drawPie(source) {
      const options = {
        title: {
          text: '各追查类型占比统计'
        },
        tooltip: {
          trigger: 'item',
          formatter: '{b} : ({d}%)'
        },
        legend: {
          orient: 'vertical',
          x: 'left',
          y: 'bottom',
          data: ['银行查控', '电话查控', '虚拟账号查控', '网站查控']
        },
        dataset: {
          source
        },
        series: {
          type: 'pie',
          label: {
            position: 'outer',
            alignTo: 'edge',
            margin: 10,
            formatter: '{@name}: {@count} ({d}%)'
          },
          encode: {
            itemName: 'name',
            value: 'count'
          }
        }
      }
      if (this.pieChart) {
        this.pieChart.setOption(options, true)
      } else {
        this.pieChart = echarts.init(this.$refs.pieChart)
        this.pieChart.setOption(options, true)
      }
    }
画柱形图

跟楼上的类似的,画柱子如楼下所示:

drawBar(source) {
      const options = {
        title: {
          text: `各月份止付金额之和统计, 合计: ${this.totalAmount}元`
        },
        dataset: {
          source
        },
        xAxis: {
          type: 'category',
          name: '时间'
        },
        yAxis: [
          {
            type: 'value',
            name: '止付金额'
          }
        ],
        series: [
          {
            type: 'bar',
            encode: {
              x: 'name',
              y: 'amount'
            },
            label: {
              normal: {
                show: true,
                position: 'top'
              }
            }
          }
        ]
      }
      if (this.barChart) {
        this.barChart.setOption(options, true)
      } else {
        this.barChart = echarts.init(this.$refs.barChart)
        this.barChart.setOption(options, true)
      }
    },

备注:考虑到需求量不大,这里笔者是为了赶进度偷懒写成这样的,学习的话,建议封装成一个个组件,例如pie.vue,bar.vue这样子去搞。

功能实现-页面权限控制和页面权限的按钮权限粒度控制

因为这个项目涉及到多个角色,这就涉及到对多个角色的页面控制了,每个角色分配的页面权限是不一样的,第二个就是进入到页面后,针对某一条记录,该登录用户按钮的权限控制。

页面权限控制

页面的权限这边有两种做法,分别是控制权在前端,和控制权在后端两种,在前端的话是通过获取用户信息的角色,根据角色去匹配,匹配中了就加到路由里面。在后端的话,就是登录的时候后端就把相应的路由返回给你,前端这边注册路由。

借着vue-element-admin的东风,笔者这边是将控制权放在前端,在路由的meta中加入roles角色去做页面的权限控制的。

参见 vue-element-admin - 路由和侧边栏:https://panjiachen.gitee.io/vue-element-admin-site/zh/guide/essentials/router-and-nav.html#%E9%85%8D%E7%BD%AE%E9%A1%B9

参见 vue-element-admin - 权限验证:https://panjiachen.gitee.io/vue-element-admin-site/zh/guide/essentials/permission.html#%E9%80%BB%E8%BE%91%E4%BF%AE%E6%94%B9

按钮权限控制

首先我们来分析下,针对我们这个系统,不外乎删除、修改、详情、审核、追查等等按钮权限,不是特别多,所以我们可以用detailmodifydeleteauditcheck等去表示这些按钮,后端在service层进行相关业务处理,把它们这些包到一个数组btnList里面返回给前端,跟前端这边做对比,如果命中那么我们就展示按钮。

核心代码如下:

template

<template v-if="column.key === 'actions'">
          <el-table-column
            :key="column.key"
            align="center"
            :width="column.width"
            :label="column.title"
          >
            <template slot-scope="scope">
              <el-button
                v-for="action in filterOperate(
                  column.actions,
                  scope.row.btnList
                )"
                :key="action.type"
                type="text"
                size="small"
                @click="handleClick(scope.row, action.type, action.title)"
              >{{ action.title }}</el-button>
            </template>
          </el-table-column>
 </template>
filterOperate(actions, btnList) {
 return actions.filter(action => btnList.includes(action.type))
}

那么我们就可以这么使用了

columns: [
 ......
  {
    title: '操作',
    key: 'actions',
    align: 'center',
    actions: [
      {
        title: '详情',
        type: 'detail'
      },
      {
        title: '修改',
        type: 'modify'
      }
    ]
  }
]

关于权限校验这块,笔者所在的供应链金融团队是这么去实现的,在保理业务中,会有很多个部门,比如市场部、财务部、风控部、董事会等等。每个部门里又有经办、审核、复核等等角色。所以在处理这类业务中的权限控制,需要将用户身上绑定一个按钮权限,比如说他是市场的经办角色,那么他就可以绑定市场经办这个角色的按钮码子上。前端这边除了要在我们楼上的基础上对列表返回的做对比之外,还有对用户的做进一步对比。这里的按钮也不能够像上面一样detailmodify这样去写,因为角色多了每个角色这么叫不好,更科学的应该是,整理成一份excel表,然后按照相应的按钮权限去配置相应的code(比如说 20001, 20002),然后根据这个去处理业务。

后端

eggjs中的三层模型(model-service-controller)

model层是对数据库的相关表进行相应的映射和CRUD,service层是处理相关的业务逻辑,controller层是为相关的业务逻辑暴露接口。这三者层序渐进,一环扣一环。

Model
一些约定
  • 原则上,不允许对Model层SQL语句返回的结果进行相关操作,返回什么就是什么。

  • 统一下数据返回的格式

    • 语法错误 null

    • 查不到数据 false

    • 查到数据 JSON | Number

  • 统一下model层文件类的通用方法

    • add:新增

    • set:更新

    • del:删除(本系统由于数据需要,所以不会真的删除这条数据,而是取一个isDelete字段去软删除它)

    • get: 获取单条数据,getById可简写成get, 若有查询条件, 按getByCondition

    • getAll: 获取多条记录,若有查询条件 按getAllByCondition

    • getAllLimit: 分页获取 若有查询条件 按getAllLimitByCondition

    • has: 若有查询条件, 按hasAttributes

目前本系统业务就用到这么多,其他的参见sequelize文档:http://sequelize.org/

这样做的好处是,一些概念和语义更加清晰了,比如有个user.js,里面用add表示新增还是addUser表示新增好,我认为是前者,在user.js里面, 除了新增user用户,难不成还有别的新增,还能新增个鬼啊。除此之外,还方便了新生代农民工的复制粘贴,提高编码效率。

抄表字段真的好累啊

试想一下这样一个场景,这个数据库有一百张表,每张表有100个字段,难道你真的要人肉去一个一个敲出来对应的数据库映射吗?那要敲到什么时候啊,人都快搞没了,我们可是新生代农民工唉,当然要跟上时代。这里介绍一下egg-sequelize-auto, 它可以快速的将数据库的字段映射到你的代码中,减少很多工作量。

安装

npm i egg-sequelize-auto -g
npm i mysql2 -g

使用

egg-sequelize-auto -h 'your ip' -d 'your database' -u 'db user' -x 'db password' -e mysql -o 'project model path' -t 'table name'

egg-sequelize-auto:https://www.npmjs.com/package/egg-sequelize-auto

sequelize连表查询的应用

在表的关系中,有一对一,一对多,多对多。本系统一对多用的比较多,这里就以银行卡结合银行的的连表做个演示。

主要是三个地方,一个是引入相关表的Model, 第二个是字段初始化,第三个是通过associate方法建立联系,阉割后的示例代码如下:

'use strict';

const OrganizationModel = require('./organization');

module.exports = app => {
  const { logger, Sequelize, utils } = app;
  const { DataTypes, Model, Op } = Sequelize;
  class BankcardModel extends Model {
    static associate() {
      const { Organization } = app.model;
      BankcardModel.belongsTo(Organization, {
        foreignKey: 'bankId',
        targetKey: 'id',
        as: 'bank',
      });
    }

    static async getAllLimit(name, prefix, bankId, { page = 0, limit = 10 }) {
      let where = {};
      if (name) {
        where = { name: { [Op.like]: `%${name}%` } };
      }
      if (prefix) {
        where.prefix = { [Op.like]: `%${prefix}%` };
      }
      if (bankId) {
        where.bankId = bankId;
      }
      where.isDelete = 0;
      try {
        const offset = page < 1 ? 1 : (page - 1) * limit;
        const total = await this.count({ where });
        const last = Math.ceil(total / limit);
        const list =
          total === 0
            ? []
            : await this.findAll({
                raw: true,
                where,
                order: [
                  ['createTime', 'DESC'],
                  ['updateTime', 'DESC'],
                ],
                offset,
                limit,
                attributes: [
                  'id',
                  'name',
                  'prefix',
                  'bankId',
                  [Sequelize.col('bank.name'), 'bankName'],
                ],
                include: {
                  model: app.model.Organization,
                  as: 'bank',
                  attributes: [],
                },
              });
        logger.info(this.getAllLimit, page, limit, where, list);
        return {
          page,
          pageSize: limit,
          list,
          total,
          last,
        };
      } catch (e) {
        logger.error(e);
        return false;
      }
    }
  }

  BankcardModel.init(
    {
      id: {
        type: DataTypes.UUID,
        defaultValue() {
          return utils.generator.generateUUID();
        },
        allowNull: false,
        primaryKey: true,
      },
      name: {
        type: DataTypes.STRING(255),
        allowNull: true,
      },
      prefix: {
        type: DataTypes.STRING(255),
        allowNull: true,
      },
      bankId: {
        type: DataTypes.STRING(255),
        allowNull: false,
        references: {
          model: OrganizationModel,
          key: 'id',
        },
      },
      isDelete: {
        type: DataTypes.INTEGER(1),
        allowNull: true,
        defaultValue: 0,
      },
      createTime: {
        type: DataTypes.INTEGER(10),
        allowNull: true,
      },
      updateTime: {
        type: DataTypes.INTEGER(10),
        allowNull: true,
      },
    },
    {
      sequelize: app.model,
      tableName: 't_bankcard',
    }
  );

  return BankcardModel;
};

sequelize中的表关系:https://sequelize.org/master/manual/assocs.html

Service

这里就是引入相关的model层写好的,然后根据业务逻辑去调用下,还是以银行卡为例

'use strict';

const { Service } = require('egg');

class BankcardService extends Service {
  constructor(ctx) {
    super(ctx);
    this.Bankcard = this.ctx.model.Bankcard;
  }

  async add(name, prefix, bankId) {
    const { ctx, Bankcard } = this;
    let result = await Bankcard.hasPrefix(prefix);
    if (result) {
      ctx.throw('卡号前缀已存在');
    }
    result = await Bankcard.add(name, prefix, bankId);
    if (!result) {
      ctx.throw('添加卡号失败');
    }
    return result;
  }

  async getAllLimit(name, prefix, bankId, page, limit) {
    const { ctx, Bankcard } = this;
    const result = await Bankcard.getAllLimit(name, prefix, bankId, {
      page,
      limit,
    });
    if (!result) {
      ctx.throw('暂无数据');
    }
    return result;
  }

  async set(id, name, prefix, bankId, isDelete) {
    const { ctx, Bankcard } = this;
    const result = await Bankcard.set(id, name, prefix, bankId, isDelete);
    if (result === null) {
      ctx.throw('更新失败');
    }
    return result;
  }
}

module.exports = BankcardService;
Controller
restful API接口

只要在相应的controller层定义相关的方法,egg程序就能够根据restful api去解析。

MethodPathRoute NameController.Action
GET/postspostsapp.controllers.posts.index
GET/posts/newnew_postapp.controllers.posts.new
GET/posts/:idpostapp.controllers.posts.show
GET/posts/:id/editedit_postapp.controllers.posts.edit
POST/postspostsapp.controllers.posts.create
PUT/posts/:idpostapp.controllers.posts.update
DELETE/posts/:idpostapp.controllers.posts.destroy

参见:https://eggjs.org/zh-cn/basics/router.html

非restful API接口

这里主要是针对于楼上的情况,进行一个补充,比如说用户,除了这些,他还有登录,登出等等操作,那这个就需要单独在router中制定了, 这里笔者封装了一个resource方法,来解析restful api的函数接口,具体如下:

'use strict';

/**
 * @param {Egg.Application} app - egg application
 */
module.exports = app => {
  const { router, controller } = app;
  router.get('/', controller.home.index);
  router.post('/user/login', controller.user.login);
  router.post('/user/logout', controller.user.logout);
  router.post('/user/info', controller.user.getUserInfo);
  router.post('/file/upload', controller.file.upload);
  router.post('/file/getall', controller.file.getAllByIds);
  router.post('/organization/by-type', controller.organization.getAllByType);
  router.post('/statistics/calculate', controller.statistics.calculate);

  function resource(path) {
    const pathArr = path.split('/');

    // 删掉第一个空白的
    pathArr.shift();

    let controllers = controller;
    for (const val of pathArr) {
      controllers = controllers[val];
    }

    router.resources(path, path, controllers);
  }

  resource('/alarm');
  resource('/bank');
  resource('/bankcard');
  resource('/mobile');
  resource('/organization');
  resource('/user');
  resource('/virtual');
  resource('/website');
  resource('/file');
  resource('/alarmCategory');
};

这里还是以银行卡为例

'use strict';

const { Controller } = require('egg');

class BankCardController extends Controller {
  async index() {
    const { ctx, service } = this;
    const { name, prefix, bankId, page, pageSize } = ctx.request.query;
    const { list, ...rest } = await service.bankcard.getAllLimit(
      name,
      prefix,
      bankId,
      Number(page),
      Number(pageSize)
    );
    const data = list.map(item => {
      const { role } = ctx.session.userinfo;
      let btnList = [];
      if (role === 'admin') {
        btnList = ['detail', 'modify', 'delete'];
      }
      return {
        btnList,
        ...item,
      };
    });
    ctx.success({ list: data, ...rest });
  }

  async create() {
    const { ctx, service } = this;
    const { name, prefix, bankId } = ctx.request.body;
    ctx.validate(
      {
        name: { type: 'string', required: true },
        prefix: { type: 'string', required: true },
        bankId: { type: 'string', required: true },
      },
      { name, prefix, bankId }
    );
    const result = await service.bankcard.add(name, prefix, bankId);
    ctx.success(result);
  }

  // async destory() {
  //   const { ctx, service } = this;
  //   const { method } = ctx;
  //   this.ctx.body = '删除';
  // }

  async update() {
    const { ctx, service } = this;
    const { id } = ctx.params;
    const { name, prefix, bankId, isDelete } = ctx.request.body;
    const result = await service.bankcard.set(
      id,
      name,
      prefix,
      bankId,
      isDelete
    );
    ctx.success(result);
  }

  async show() {
    const { ctx, service } = this;
    const { method } = ctx;
    this.ctx.body = '查询';
  }

  async new() {
    const { ctx, service } = this;
    const { method } = ctx;
    this.ctx.body = '创建页面';
  }

  async edit() {
    const { ctx, service } = this;
    const { method } = ctx;
    this.ctx.body = '修改页面';
  }
}

module.exports = BankCardController;

至此,打通这样一个从model到service再到controller的流程,

eggjs中的定时任务schedule

原系统是接入了第三方的数据源去定时读取更新数据,再将数据清洗更新到我们自己的t_alarm表,一些原因这里我不方便做演示,所以笔者又新建了一张天气表,来向大家介绍eggjs中的定时任务。

在这里,我相中了万年历的接口,准备嫖一嫖给大家做一个演示的例子,它返回的数据格式如下

{
  "data": {
    "yesterday": {
      "date": "19日星期四",
      "high": "高温 33℃",
      "fx": "东风",
      "low": "低温 24℃",
      "fl": "<![CDATA[1级]]>",
      "type": "小雨"
    },
    "city": "杭州",
    "forecast": [
      {
        "date": "20日星期五",
        "high": "高温 34℃",
        "fengli": "<![CDATA[2级]]>",
        "low": "低温 25℃",
        "fengxiang": "西南风",
        "type": "小雨"
      },
      {
        "date": "21日星期六",
        "high": "高温

以上是关于基于Vue2和Node.js的反欺诈系统设计与实现的主要内容,如果未能解决你的问题,请参考以下文章

基于node.js网上蛋糕店系统的设计与实现(论文+项目源码)

基于node.js的网上书店系统的设计与实现.rar(项目源码+论文)(开发文档+nodejs配置+安装+运行教学.zip)

基于三明治结构深度学习框架的金融反欺诈模型研究与应用

基于ARM处理器的反汇编器软件简单设计及实现

基于Java+SpringBoot+vue+node.js的智能农场管理系统详细设计和实现

基于Java+SpringBoot+vue+node.js等疫情网课管理系统详细设计和实现