【实战教程】只需三步,用云函数又快又安全地实现小程序支付

【实战教程】只需三步,用云函数又快又安全地实现小程序支付
本文主要侧重于讲述小程序在线支付功能中的编程思想和编程模式,并在必要的地方提供关键代码示例。(文末也将附上关键的 js 代码)

为方便演示,这里将实现一个最简单的虚拟商品的订单支付功能,订单略去了收货地址和多规格、多数量的情况,示例中仅讨论在商品详情页中直接创建订单并发起支付的情况。需要分别定义 Product 表和 Order 表进行数据存取,在 BaaS 后台中创建两张数据表。

一、数据表结构设计

Product 表:

数据表录入权限:所有人

数据行读写权限:创建者可写,所有人可读

【实战教程】只需三步,用云函数又快又安全地实现小程序支付

Order 表:

数据表录入权限:所有人

数据行读写权限:创建者可写,创建者可读

【实战教程】只需三步,用云函数又快又安全地实现小程序支付

商品的订单结算和支付流程一般包括“创建订单 -> 支付 -> 更新订单状态”三个步骤。下文中将分析几种实现该流程的方案,供我们一起探讨。

二、客户端创建订单,客户端更新订单状态

我们先来看下只在客户端中如何处理这些逻辑。

1) 创建订单:Order 表中创建一条新记录,status 字段默认值为 “no_paid”,保存订单金额,商品快照和商品 id 以及订单创建者,其中订单创建者由 BaaS 的用户系统自动处理,值为创建订单的用户 id:

/**
 * 创建订单处理函数
 */
createOrderHandle() {
 const orderTableId = 12345678
 const tableObject = new wx.BaaS.TableObject(orderTableId)
 const createObject = tableObject.create()


 const product = this.data.product
 const data = {
  product_id: product.id,
  product_snapshot: product,
  total_cost: product.price,
  status: 'no_paid',
 }


 // 客户端创建订单,客户端更新订单状态
 return createObject.set(data).save().then(res => {
  this.order = res.data || {}
  return this.pay(this.order)
 }).then(transactionNo => {
  return this.updateOrder(transactionNo)
 }).then(res => {
  wx.navigateTo({ url: '../order/order' })
 })


2)支付:调用 BaaS SDK 提供的支付方法 wx.BaaS.pay,调起微信支付:

/**
 * 发起微信支付
 * @param {Object} order
 */
pay(order) {
 const product = this.data.product
 const orderTableId = 12345678
 const params = {
  totalCost: order.total_cost,
  merchandiseDescription: product.title,
  merchandiseSchemaID: orderTableId,
  merchandiseRecordID: order.id,
  merchandiseSnapshot: product,
 }
 return wx.BaaS.pay(params).then(res => {
  return res.transaction_no
 })
}


3)更新订单状态:支付成功后,更新 status 字段值为 "paid",并更新微信支付序列号:

/**
 * 更新订单状态
 * 仅在由客户端更新订单状态时使用
 * @param {String} transaction_no 支付成功后由微信返回的微信支付序列号
 */
updateOrder(transaction_no) {
 const orderTableId = 12345678
 const tableObject = new wx.BaaS.TableObject(orderTableId)
 const recordId = this.order.id
 const record = tableObject.getWithoutData(recordId)


 record.set('status', 'paid')
 record.set('transaction_no', transaction_no)
 return record.update()
}

我们从整体上来看支付流程,便能发现订单状态实质上是由客户端中 updateOrder 方法发起请求来进行更新的。

而这一情况将导致极大的安全隐患。因为从原则上来说,我们认为来自客户端的信息都是不可信的,订单状态很容易被伪造出的一个请求跳过支付直接将状态更新为 ‘paid’,并更新一个假的 transaction_no。

这意味着,不花一分钱也能将订单变为已支付。在生产环境中,任何情下都不应该使用这种支付流程。

三、客户端创建订单,触发器更新订单状态

基于这种情况,你或许会想:既然由客户端来更新订单状态会引起安全问题,又没有后端开发者参与,要怎么做?

BaaS 平台中触发器和云函数可以帮你解决这个问题。它们可以完成这种非客户端的处理逻辑,同时使用它们的时候跟开发后端应用又有很大的不同。

首先来看一下触发器(Trigger),触发器是一种当触发条件被满足,将会执行触发器中的事先定义的动作,定义好的动作可以是操作数据库或者调用云函数。

我们希望当支付完成之后,触发器可以帮我们自动地操作数据库,更新订单对应的 status 和 transaction_no 字段。触发器设置如下:

【实战教程】只需三步,用云函数又快又安全地实现小程序支付
【实战教程】只需三步,用云函数又快又安全地实现小程序支付
【实战教程】只需三步,用云函数又快又安全地实现小程序支付

「触发类型」选择微信支付回调,条件是支付成功后执行触发器。一般触发器类型常见的还有操作数据表,定时任务等,分别对应操作数据表后触发和定时触发。

「动作」定义了触发器将要执行的操作,这里是更新 Order 数据表对应的 status、total_cost 和 transaction_no 字段。更多触发器的具体细节,不同平台的实现有所不同,在此不展开讨论。

借助触发器,客户端创建订单成功后不需要再调 updateOrder 方法,Order 订单的数据会自动更新成支付成功对应的状态:

/**
 * 创建订单处理函数
 */
createOrderHandle() {
 ... // 与上文相同
 
 // 客户端创建订单,触发器自动更新订单状态
 return createObject.set(data).save().then(res => {
  this.order = res.data || {}
  return this.pay(this.order)
 }).then(res => {
  wx.navigateTo({ url: '../order/order' })
 })
}

值得注意的是,上面介绍的第一种方案中 Order 表的 ACL 数据行读写权限是创建者可写的,意味着创建者可以对数据进行任意操作,将更新订单状态的工作交给触发器后,Order 表的 ACL 数据行读写权限应设置为「不可写」,保证 Order 表的数据创建后不会由外部更改,提高了数据的安全性。

四、云函数创建订单,触发器更新订单状态

细心的读者可能发现了除了 status 和 transacton_no 字段外,还由触发器自动更新了 total_cost 字段,保存的是实际支付的金额。

这就引出了另外一个问题,虽然现在不能通过客户端修改订单状态,但是创建订单的所有数据仍是由客户端发起请求,在请求参数中定义的,这种方式同样很容易被人篡改数据,比如 1000 元的商品可以被更改成 1 元甚至 0 元,造成只需要花很少的钱就可以买到高价值的商品。

使用触发器自动根据微信支付回调更新 total_cost 可以保证无论何种情况下,数据中保存的都是最终用户实际支付的金额。虽然这种方式可以事后帮助我们发现订单金额异常的问题,但还是不能解决在创建订单时金额被篡改的问题,这又要如何解决呢?

这时候创建订单的功能应该交给后端逻辑去做了,在 BaaS 平台中就需要用到云函数了,云函数又被称为 FaaS(Functions as a Service)函数即服务。

云函数是一段可以部署在服务端的代码,关键词是一段代码,而不是一整套的后端逻辑,它本质上就是函数而已,特别是对于运行在 node.js 环境下的云函数来说,它跟平常所写的 JavaScript 代码几乎一模一样,对前端开发者来说非常容易上手。云函数可以由 SDK 或触发器调用,也可以在云函数之间相互调用。

为了避免创建订单时客户端数据篡改或商品信息不能实时同步的问题,我们将创建订单的逻辑迁移到 BaaS 平台的云函数中:

【实战教程】只需三步,用云函数又快又安全地实现小程序支付

关注「知晓云」微信公众号,在微信后台回复「创建订单」,获取完整的【创建订单】云函数源码。

调用该云函数时传入商品 id,云函数先查出此商品的具体信息,再使用该商品信息来创建订单,整个过程在 BaaS 平台的云函数系统中完成,保证了数据的准确性。支付完成后,触发器同样会自动更新订单状态。客户端中使用 invokeFunction 方法调用云函数:

/**
 * 创建订单处理函数
 */
createOrderHandle() {
 ... // 与上文相同
 
 // 使用云函数创建订单,触发器更新订单状态
 wx.BaaS.invokeFunction('createOrder', {
  product_id: this.data.product.id
 }).then(res => {
  this.order = res.data || {}
  return this.pay(this.order)
 }).then(res => {
  wx.navigateTo({ url: '../order/order' })
 })
}

由于创建订单和更新订单的操作已经分别交由云函数和触发器处理了,为了更好的安全性,Order 表的数据创建权限和修改权限都不应该对客户端开放。

需要额外说明的是,而触发器和云函数系统级别的操作,相当于拥有最高权限,所以我们这里相当于禁止了客户端中除了读取数据外的所有操作,也就使得 Order 表的权限控制和数据的准确性得到了安全的保障。

五、云函数创建订单,云函数校验并更新订单状态

我们再来研究一下代码,在 pay 这个方法中 wx.BaaS.pay(params) 所做的事情实际上是发起一个请求,获取 BaaS 系统返回的支付解密数据,然后使用这些支付解密数据调用微信客户端的支付功能,最终由用户输入密码完成支付。

同理,根据客户端提供的数据都不可信的原则,这个请求中 params 参数时的数据同样可以被伪造,比如修改掉 totalCost 的值,也会导致最终支付的金额跟实际应该支付的金额不一值,根据之前触发器的设定,虽然会如实地记录了最终支付的金额,可以为后台追溯金额异常的订单提供依据,但是并不会阻止订单更新为已支付的状态。

当用户支付成功后,我们更希望在更新订单状态前可以先进行支付数据的校验,校验不通过则不更新订单状态。想要实现这个功能,则要将触发器和云函数进行搭配使用了。

先将触发器的动作类型改为云函数:

【实战教程】只需三步,用云函数又快又安全地实现小程序支付

微信支付成功后会触发调用 verifyPayment 云函数:

【实战教程】只需三步,用云函数又快又安全地实现小程序支付

客户端的代码保持不变,此时整个流程是:调用 createOrder 云函数创建订单,拿到创建订单成功的回调数据后,发起支付,支付成功之后,由触发器自动调用 verifyPayment 云函数,校验实付金额是否跟该商品的价格一致,若一致则更新该订单为已支付状态。

在 verifyPayment 云函数中只考虑了校验实付金额这一个维度,在实际开发中应综合考虑更多维度来确保数据准确,在此不再展开讨论。

至此,本文完成了一个小程序在线支付的案例,介绍了如何借助 BaaS 平台最快地实现小程序在线支付功能,通过开发过程中发现的各种安全问题,迭代出四种不同的实现方案,一步步完善支付功能的安全性,最后得出一个最快最安全实现小程序在线支付的方案

六、商品详情页和云函数 js 代码

商品详情页 js 代码

/** 商品详情页 js 代码 **/
const productTableId = 12345678
const orderTableId = 123456789

Page({
 data: {
  product: {}
 },

 onLoad(options) {
  // 设置默认的商品 id,方便调试
  const productId = options.id || '5ade97135acfb521865bf766'
  this.getProductDetail(productId)
 },
 /**
  * 获取商品详情信息
  * @param {String} id
  */
 getProductDetail(id) {
  const tableObject = new wx.BaaS.TableObject(productTableId)
  const query = new wx.BaaS.Query()

  query.compare('id', '=', id)
  tableObject.setQuery(query).find().then(res => {
   const objects = res.data.objects || []
   const product = objects[0] || {}
   this.setData({ product })
  })
 },
 /**
  * 点击立即购买按钮事件
  */
 createOrder(e) {
  wx.getSetting({
   success: res => {
    if (res.authSetting['scope.userInfo']) {
     this.createOrderHandle()
    } else {
     wx.BaaS.login()
    }
   }
  })
 },
 

 /**
  * 创建订单处理函数
  */
 createOrderHandle() {
  const tableObject = new wx.BaaS.TableObject(orderTableId)
  const createObject = tableObject.create()

  const product = this.data.product
  const data = {
   product_id: product.id,
   product_snapshot: product,
   total_cost: product.price,
   status: 'no_paid',
  }
  
  // 客户端创建订单,客户端更新订单状态
  // return createObject.set(data).save().then(res => {
  //  this.order = res.data || {}
  //  return this.pay(this.order)
  // }).then(transactionNo => {
  //  return this.updateOrder(transactionNo)
  // }).then(res => {
  //  wx.navigateTo({ url: '../order/order' })
  // })

  // 客户端创建订单,触发器更新订单状态
  // return createObject.set(data).save().then(res => {
  //  this.order = res.data || {}
  //  return this.pay(this.order)
  // }).then(res => {
  //  wx.navigateTo({ url: '../order/order' })
  // })

  // 使用云函数创建订单,触发器或云函数更新订单状态
  wx.BaaS.invokeFunction('createOrder', {
   product_id: this.data.product.id
  }).then(res => {
   this.order = res.data || {}
   return this.pay(this.order)
  }).then(res => {
   wx.navigateTo({ url: '../order/order' })
  })
 },
 /**
  * 发起微信支付
  * @param {Object} order
  */
 pay(order) {
  const product = this.data.product
  const params = {
   totalCost: order.total_cost,
   merchandiseDescription: product.title,
   merchandiseSchemaID: orderTableId,
   merchandiseRecordID: order.id,
   merchandiseSnapshot: product,
  }
  return wx.BaaS.pay(params).then(res => {
   return res.transaction_no
  })
 },
 /**
  * 更新订单状态
  * @param {String} transaction_no 支付成功后返回的微信支付订单号
  */
 updateOrder(transaction_no) {
  const tableObject = new wx.BaaS.TableObject(orderTableId)
  const recordId = this.order.id
  const record = tableObject.getWithoutData(recordId)

  record.set('status', 'paid')
  record.set('transaction_no', transaction_no)
  return record.update()
 }
})

创建订单云函数

/** 创建订单云函数 **/
const productTableId = 12345678
const orderTableId = 123456789

exports.main = function createOrder(event, callback) {
 const {product_id} = event.data
 const user_id = event.request.user.id
 
 getProductDetail(product_id).then(product => {
  return createOrderHandel(product, user_id)
 }).then(res => {
  const order = res.data || {}
  callback(null, order)
 }).catch(err => {
  callback(err)
 })
}

function getProductDetail(id) {
 const tableObject = new BaaS.TableObject(productTableId)

 const query = new BaaS.Query()
 query.compare('id', '=', id)
 return tableObject.setQuery(query).find().then(res => {
  const objects = res.data.objects || []
  const product = objects[0] || {}
  return product
 })
}

function createOrderHandel(product, user_id) {
 const tableObject = new BaaS.TableObject(orderTableId)
 const createObject = tableObject.create()

 const data = {
  product_id: product.id,
  product_snapshot: product,
  total_cost: product.price,
  status: 'no_paid',
  created_by: user_id
 }
 return createObject.set(data).save()
}

校验并更新订单状态云函数

/** 校验并更新订单状态云函数 **/
const productTableId = 12345678
const orderTableId = 123456789

exports.main = function verifyPayment(event, callback) {
 const data = event.data
 const totalCost = data.total_cost
 const orderId = data.merchandise_record_id
 const transactionNo = data.transaction_no
 const merchandiseSnapshot = data.merchandise_snapshot
 const productId = merchandiseSnapshot.id

 getProductDetail(productId).then(product => {
  if (product.price === totalCost) {
   updateOrder(orderId, transactionNo)
  }
 })
}

function getProductDetail(id) {
 const tableObject = new BaaS.TableObject(productTableId)

 const query = new BaaS.Query()
 query.compare('id', '=', id)
 return tableObject.setQuery(query).find().then(res => {
  const objects = res.data.objects || []
  const product = objects[0] || {}
  return product
 })
}

function updateOrder(orderId, transaction_no) {
 const tableObject = new BaaS.TableObject(orderTableId)
 const recordId = orderId
 const record = tableObject.getWithoutData(recordId)

 record.set('status', 'paid')
 record.set('transaction_no', transaction_no)
 return record.update()
}


知晓云是国内首家专注于小程序开发的后端云服务。使用知晓云,小程序开发快人一步。