基于MemFire云函数实现微信支付

背景

现在假设你需要使用MemFire的BaaS服务实现一个“极简的卖书”应用,你该怎么设计?本文会讲解如何简单又安全的实现这个应用。

业务分析

应用的核心业务逻辑是:用户选择喜欢的图书,加入购物车后,能够通过微信完成支付。

我们将该业务逻辑的实体对象与操作拆解出来,分别是:用户,图书,支付

  • 如何区分用户:应用可以要求用户登录,通过登录的身份标识来区分。因此我们可以利用MemFire BaaS服务提供的认证管理工作快速实现。
  • 如何管理图书:应用创建后,可以通过新建一张图书的表,在该表中灌入图书的初始数据。
  • 用户如何支付:根据选择书籍的总金额,利用微信支付的接口完成支付调用。

数据表设计

  • 用户表:无需设计,BaaS服务自带
  • 书籍表:books
  • 我们将用户下单这个操作形成的结果叫做“创建交易”。交易表:transactions

该表中的uid关联的是auth.user表中的id字段,标识用户;表中的open_id代表微信用户,一样是标识用户的,不过只有使用微信小程序完成微信支付时才需要;status标识支付状态,默认为READY

支付接口实现

首先思考一个问题,如何保证支付信息是不可被恶意篡改的。简单点说,如果我们直接通过前端调用微信支付,那么支付金额可能被用户通过页面调试或网络劫持的方式篡改,那如何保证用户无法修改支付信息呢(包括支付金额,支付状态)。

再思考一个问题,类似于微信支付等许多第三方接口都是异步的,它们通过你提供的回调地址将异步操作的结果返回给你,那现在你的接口都是由BaaS服务提供的,你怎么创建回调函数呢?

为了解决上述说的两个问题,MemFire提供了云函数这个模块,它允许你通过编写JavaScript函数,实现接口服务的部署。

通过上传你的代码包,配置环境变量即可快速实现微信支付的接口部署。

我们打开微信支付代码包中的index.js文件,进行分析:

   const { v4: uuidv4 } = require('uuid');
 const WxPay = require('wechatpay-node-v3')
 const sup = require('@supabase/supabase-js')
 
 // 支付客户端和supabase客户端
 let pay
 let supabase
 
 // 云函数生命周期,初始化时,初始化supabase和pay
 exports.initializer = (context, callback) => {
     const publicKeyPem = process.env.publicKey
     const privateKeyPem = process.env.privateKey
 
     try {
 
         // 初始化支付客户端
         pay = new WxPay({
           appid: process.env.appId,
           mchid: process.env.mchId,
           publicKey: formatPublicKey(publicKeyPem),
           privateKey: formatPrivateKey(privateKeyPem),
         })
 
         // 初始化supabase客户端
         supabase = sup.createClient(process.env.API_URL, process.env.SERVICE_ROLE_KEY)
 
     } catch (e) {
 
         console.log('initializing failed')
         callback(e)
 
     }
 
     // 如果不执行如下行,会导致无法退出initialize
     callback(null, 'successful');
 
 }
 
 // 云函数入口文件
 exports.handler = async (req, resp, context) => {
 
     // 解决跨域问题
     resp.setHeader('Access-Control-Allow-Origin', '*') // *可以改成你的服务域名
     resp.setHeader('Access-Control-Allow-Methods', '*');
     resp.setHeader('Access-Control-Allow-Headers', '*');
     resp.setHeader('Access-Control-Max-Age', '3600');
 
     /** 请求参数
      * method: 请求方法
      * queries: 请求参数
      * headers: 请求头
      * body: 请求体, 为 Buffer 类型
    */
     const { method, queries, headers, body } = req
 
     const { action } = queries
 
     // 定义用户的id
     let userId
 
     // OPTIONS的时候不检查
     if (req.method !== 'OPTIONS' && !headers['wechatpay-timestamp']) {
 
         // 利用用户的令牌创建一个匿名的supabase client
         const anonClient = sup.createClient(
             process.env.API_URL,
             process.env.ANON_KEY,
             { global: { headers: { Authorization: req.headers.authorization }}}
             )
             // 获取请求用户信息
             const { data: { user }, error} = await anonClient.auth.getUser();
 
             // 获取用户错误,说明token无效
             if (error) {
             resp.setStatusCode(401)
             resp.setHeader('Content-Type', 'application/json')
             resp.send(JSON.stringify({code: 401, msg: 'forbidden'}))
             }
 
             // 请求中绑定用户的id
             userId = user.id
     }
 
     // 解决跨域问题
     if (req.method === 'OPTIONS') {
         resp.setStatusCode(204)
         resp.send('');
     } else if (method === 'GET' && action === 'prepay') {
 
         // 获取请求参数
         const {  tradeId, openId } = queries
 
         const { data, error} = await supabase
             .from('transactions')
             .select('books')
             .eq('id', tradeId)
             .eq('uid', userId) // 这里就限制了用户只能操作自己的订单
             .single()
 
         const bookIds = data.books.map(item => item.id)
       if (error) {
         // 返回响应
         resp.setStatusCode(403)
         resp.setHeader('Content-Type', 'application/json')
         resp.send(JSON.stringify({
           status: 403
         }))
       }
 
       const { data: books, error: err } = await supabase
         .from('books')
         .select('price')
         .in('id', bookIds)
 
         const amount = books.reduce((sum, book) => sum + book.price, 0)
         const description = '发起支付时间-' + new Date().toISOString()
         const notifyUrl = process.env.notifyUrl // 通过环境变量传进来
 
         // 下单
         const tradeNo = convertTradeID(tradeId)
         const result = await getOrder(description, tradeNo, notifyUrl, amount, openId)
 
         resp.setStatusCode(result.status)
         resp.setHeader('Content-Type', 'application/json')
         resp.send(JSON.stringify(result))
 
     } else if (method === 'POST' && headers['wechatpay-timestamp']) {
 
         let isSuccessed = false
         let tradeId = ''
 
         try {
             const result = JSON.parse(req.body.toString())
 
             const { ciphertext, associated_data, nonce } = result.resource
 
             const decodeContent = pay.decipher_gcm(ciphertext, associated_data, nonce, process.env.apiKey)
 
             tradeId = str2UUID(decodeContent.out_trade_no)
             console.log(decodeContent.trade_state)
             console.log(tradeId)
 
             const { data, error } = await supabase.from('transactions').update({ status: decodeContent.trade_state }).eq('id', tradeId)
 
         } catch (err) {
 
             resp.setStatusCode(500)
             resp.setHeader('Content-Type', 'application/json')
             resp.send(JSON.stringify({
                 code: 'FAIL',
                 message: '失败'
             }))
         }
 
         resp.setStatusCode(200)
         resp.setHeader('Content-Type', 'application/json')
         resp.send(JSON.stringify({
             code: 'SUCCESS',
         }))
 
     } else if (method === 'GET' && action === 'querypay') {
         // 获取请求参数
         const { tradeId, tag } = queries
 
         // 查询订单
         const tradeNo = convertTradeID(tradeId)
         const result = await queryOrder(tradeNo, tag)
 
         // 返回响应
         resp.setStatusCode(result.status)
         resp.setHeader('Content-Type', 'application/json')
         resp.send(JSON.stringify(result))
 
     } else if (method === 'GET' && action === 'closepay') {
         // 获取请求参数
         const { tradeId } = queries
 
         // 关闭订单
         const tradeNo = convertTradeID(tradeId)
         const result = await closeOrder(tradeNo)
 
         // 标记数据库交易状态
         await supabase.from('transactions').update({ status: 'CLOSED' }).eq('id', tradeId)
 
         // 返回响应
         resp.setStatusCode(result.status)
         resp.setHeader('Content-Type', 'application/json')
         resp.send(JSON.stringify(result))
     }
 
     // ---------------------------------------- start 退款流程: 按需开启 -------------------------
     // 申请退款
     else if (method === 'GET' && action === 'refund') {
         // 获取请求参数
         const { tradeId, refund } = queries
 
         // const refundId = 'refundId' // 退款单号, 需要自己生成
         const { data, error} = await supabase
         .from('transactions')
         .select('books')
         .eq('id', tradeId)
         .eq('uid', userId) // 这里就限制了用户只能操作自己的订单
         .single()
 
         if (error) {
             resp.setStatusCode(403)
             resp.setHeader('Content-Type', 'application/json')
             resp.send(JSON.stringify({
               status: 403
             }))
         }
 
         const total = data.books.reduce((sum, i) => i.price + sum, 0)
         const refundId = uuidv4()
 
         // 退款
         const tradeNo = convertTradeID(tradeId)
         const result = await refunds(tradeNo, refundId, Number(refund), total)
 
         await supabase.from('refunds').insert({ id: refundId, transaction_id: tradeId, status: 1, refund: refund, reason: '退款'})
 
         // 返回响应
         resp.setStatusCode(200)
         resp.setHeader('Content-Type', 'application/json')
         resp.send(JSON.stringify(result))
 
     // 查询退款
     } else if (method === 'GET' && action === 'queryrefund') {
         // 获取请求参数
         const { refundId } = queries
 
         // 查询退款
         const result = await queryRefund(refundId)
 
         // 返回响应
         resp.setStatusCode(result.status)
         resp.setHeader('Content-Type', 'application/json')
         resp.send(JSON.stringify(result))
 
     // 其他请求,均返回错误消息
     }
     // ---------------------------------------- end 退款流程 -------------------------
     else {
         resp.setStatusCode(200)
         resp.setHeader('Content-Type', 'application/json')
         resp.send(JSON.stringify({
             code: 404,
             msg: 'not found'
         }))
     }
 
 }
 
 // 创建于支付订单,支付链接(native模式可以将其生成二维码供用户扫码支付),传入openId,则是小程序支付
 async function getOrder(description, tradeNo, notifyUrl, amount, openId = undefined) {
     const params = {
         description,
         out_trade_no: tradeNo,
         notify_url: notifyUrl,
         amount: {
             total: amount
         }
     }
 
     let result
 
     if (openId) {
         result = await pay.transactions_jsapi({...params, payer: { openid: openId }})
     } else {
         result = await pay.transactions_native(params)
     }
 
     console.log(result)
     return result
 
 }
 
 // 查询订单,通过商户订单号查询或者微信订单号查询,通过tag区分,默认通过订单号查询
 async function queryOrder(tradeNo) {
     const result = await pay.query({out_trade_no: tradeNo })
 
     console.log(result)
     return result
 
 }
 
 // 关闭订单
 async function closeOrder(tradeNo) {
     const result = await pay.close(tradeNo)
 
     console.log(result)
     return result
 
 }
 
 // 申请退款
 async function refunds(tradeNo, refundId, refund, total) {
     const params = {
         out_trade_no: tradeNo,
         out_refund_no: refundId, // 自己传一个进来,存起来,后续用来查
         reason: 'refund',
         amount: {
             refund,
             total,
             currency: 'CNY'
         }
     }
 
     const result = await pay.refunds(params)
 
     console.log(result)
     return result
 
 }
 
 // 查询单笔退款
 async function queryRefund(refundId) {
     const result = await pay.find_refunds(refundId)
 
     console.log(result)
     return result
 
 }
 
 function convertTradeID(tradeId) {
     const regex = /-/g
     return tradeId.replace(regex, '')
 }
 
 function str2UUID(str) {
     if (str.length !== 32) {
       throw new Error('Input string must be 32 characters long.');
     }
 
     const uuid = [
       str.slice(0, 8),
       str.slice(8, 12),
       str.slice(12, 16),
       str.slice(16, 20),
         str.slice(20),
     ].join('-');
 
     return uuid;
 }
 
 function formatPublicKey(rawPublicKey) {
   const keyHeader = '-----BEGIN CERTIFICATE-----';
   const keyFooter = '-----END CERTIFICATE-----';
 
     const regex = /\s/g;
   const str = rawPublicKey.replace(regex, '')
   // 按64个字符一行分割密钥
   const formattedKey = str.match(/.{1,64}/g).join('\n');
 
   return `${keyHeader}\n${formattedKey}\n${keyFooter}`;
 }
 
 function formatPrivateKey(rawPrivateKey) {
   const keyHeader = '-----BEGIN PRIVATE KEY-----';
   const keyFooter = '-----END PRIVATE KEY-----';
 
   const regex = /\s/g;
   const str = rawPrivateKey.replace(regex, '')
   // 按64个字符一行分割密钥
   const formattedKey = str.match(/.{1,64}/g).join('\n');
 
   return `${keyHeader}\n${formattedKey}\n${keyFooter}`;
 }
 
 // 申请交易账单 date的格式是yyyy-MM-dd
 // async function applyTradeBill(date) {
 //     const result = await pay.tradebill({
 //         bill_date: date,
 //         bill_type: 'ALL'
 //     })
 //
 //     console.log(result)
 //     return result
 //
 // }
 
 // 申请资金账单
 // async function applyFundBill(date) {
 //     const result = await pay.fundflowbill({
 //         bill_date: date,
 //         account_type: 'BASIC'
 //     })
 //
 //     console.log(result)
 //     return result
 //
 // }
 
 // 下载账单, 这个url是上两个接口返回的
 // async function downloadBill(url) {
 //     const result = await pay.downloadbill(url)
 //
 //     console.log(result)
 //     return result
 //
 // }
  

该代码的注释已经表明了代码块的用途。针对该云函数,你需注意如下几个问题的解决手段:

  • 云函数一旦部署,会生成一个可调用的url给用户,但是如何保证请求该URL时,接口不会报跨域错误:
            // 解决跨域问题
          resp.setHeader('Access-Control-Allow-Origin', '*') // *可以改成你的服务域名
          resp.setHeader('Access-Control-Allow-Methods', '*');
          resp.setHeader('Access-Control-Allow-Headers', '*');
          resp.setHeader('Access-Control-Max-Age', '3600');
      ```

然后当接口遇到options请求方法时,返回204

 - 云函数是如何判断用户的?通过请求头来获取用户信息
```JavaScript
      // 利用用户的令牌创建一个匿名的supabase client
      const anonClient = sup.createClient(
          process.env.API_URL,
          process.env.ANON_KEY,
          { global: { headers: { Authorization: req.headers.authorization }}}
          )
          // 获取请求用户信息
          const { data: { user }, error} = await anonClient.auth.getUser();
      ```
  - 云函数怎么判断支付金额?通过查询交易ID得到书籍ID,然后通过书籍ID得到了金额,并进行了加总。这样就避免了前端窜改金额。

```JavaScript
  const { data, error} = await supabase
      .from('transactions')
      .select('books')
      .eq('id', tradeId)
      .eq('uid', userId) // 这里就限制了用户只能操作自己的订单
      .single()
  
  const bookIds = data.books.map(item => item.id)
  if (error) {
      // 返回响应
      resp.setStatusCode(403)
      resp.setHeader('Content-Type', 'application/json')
      resp.send(JSON.stringify({
        status: 403
      }))
  }
  
  const { data: books, error: err } = await supabase
  .from('books')
  .select('price')
  .in('id', bookIds)
  
  const amount = books.reduce((sum, book) => sum + book.price, 0)
  

自己的应用调用上述云函数,间接实现了微信支付的调用,同时保证了调用安全性。在代码中,你会看到很多process.env.xxx的写法,这其实是配置的云函数环境变量。

有了环境变量,可以更方便的修改服务,而无需重新部署。

针对上图环境变量的含义,在此做出解释:

  • apiKey: 验证微信支付调用的密钥,从微信商户后台获得
  • appId:小程序ID
  • mchId:商户ID
  • notifyUrl:微信回调地址
  • privateKey:微信商户的APIv3私钥
  • publicKey:微信商户的APIv3公钥

这里要重点说明的是notifyUrl,由于只有部署了云函数,才能获得云函数的调用地址,因此notifyUrl只能在部署云函数之后,才能填写可用地址。

小程序应用实现

通过前文的描述,小程序需要实现认证登录、图书选择、下单支付等逻辑。

微信认证实现

申请小程序之后,会得到小程序的ID和密钥,将其填写在服务上这里即可。

那么在微信小程序这边只需要调用一个函数即可实现

  // index.js
import { supabase } from '../../lib/supabase'

// 获取应用实例
const app = getApp()

Page({
  data: {},

  onLoad() {
    if (wx.getUserProfile) {
      this.setData({
        canIUseGetUserProfile: true
      })
    }
  },

  login() {
    wx.login({
        success: async res => {
            // 通过这一行代码,即可实现微信认证,着实方便
            const { data, error } = await supabase.auth.signInWithWechat({ code: res.code })

            if (error) {
                wx.showToast({
                  title: '微信认证失败',
                  icon: 'none',
                  duration: 2000
                })
            } else if (data) {
                wx.navigateTo({
                  url: '/pages/books/index',
                })
            }
        },
        fail: (err) => {
            wx.showToast({
              title: err.errMsg,
              icon: 'none',
              duration: 2000
            })
        }
    })
  }
})
  

图书选择实现

通过MemFire BaaS服务自动生成的Restful接口,可以很方便的获取数据表中的数据(这里指的是图书列表)

  import { supabase } from "../../lib/supabase"

// pages/books/index.js
Page({

    /**
     * 页面的初始数据
     */
    data: {
        books: [],
        bookIds: [],
        selectedBooks: []
    },

    /**
     * 生命周期函数--监听页面加载
     */
    async onLoad(options) {
        const { data, error } = await supabase.from('books').select('*')
        const bookIds = data.data.map((i) => i.id)
        this.setData({ books: data.data, bookIds: bookIds })

    },

    onChange(e) {
        this.setData({ selectedBooks: e.detail.value })
    },

    async goToPay() {

        const uid = JSON.parse(wx.getStorageSync('sb-cgj3qoi5g6h9k9li2g30-auth-token')).user.id

        const goods = this.data.books.filter(item => this.data.selectedBooks.indexOf(item.id) > -1)

        const { data, error } = await supabase.from('transactions')
            .insert({ uid, books: goods, updated_at: new Date().toISOString() })
            .select()
            .single()

        if (error) {
            wx.showToast({
              title: '暂时无法创建订单',
            })
        } else {
            wx.navigateTo({
              url: `/pages/trade/index?tradeid=${data.data.id}`
            })
        }
        
    }
})
  

交易支付

在支付页面,只需要调用前面部署好的实现了微信支付接口调用的云函数即可

  import { supabase } from "../../lib/supabase"

// pages/trade/index.js
Page({

    /**
     * 页面的初始数据
     */
    data: {
        books: [],
        total: 0,
        tradeId: ''
    },

    /**
     * 生命周期函数--监听页面加载
     */
    async onLoad(options) {
        let trade_id

        try {
            trade_id = options.tradeid
            this.setData({tradeId: trade_id})
        } catch (error) {
            wx.showToast({
              title: '无法获取交易订单,请重新下单',
            })

            setTimeout(() => {
                wx.navigateTo({
                  url: '/pages/books/index',
                })
            }, 2000)
        }

        const { data, error } = await supabase.from('transactions').select('books').eq('id', trade_id).single()

        if (error) {
            wx.showToast({
                title: '无法获取交易订单,请重新下单',
              })
  
              setTimeout(() => {
                  wx.navigateTo({
                    url: '/pages/books/index',
                  })
              }, 2000)
        }

        this.setData({books: data.data.books})
        const sum = data.data.books.reduce((sum, i) => sum + i.price, 0)
        this.setData({total: sum/100})
    },

    async goPay() {
        // 从本地session中获取openId信息和token信息
        const openId = JSON.parse(wx.getStorageSync('sb-xxxx-auth-token')).user.wechat_id
        const token = JSON.parse(wx.getStorageSync('sb-xxxx-auth-token')).access_token
        wx.request({
          url: 'https://your_cloud_function_url/pay',
          method: 'GET',
          data: {
              openId,
              tradeId: this.data.tradeId.replace(/-/g, ''),
              action: 'prepay'
          },
          header: {
              'Authorization': 'Bearer ' + token
          },
          success:  (res) => {
              console.log(res)
              const { timeStamp, nonceStr, signType, paySign } = res.data
              wx.requestPayment({
                timeStamp: timeStamp,
                nonceStr: nonceStr,
                package: res.data.package,
                signType: signType,
                paySign: paySign,
                success (res) { 
                    console.log(res)
                },
                fail (res) { 
                    console.log(res)
                }
              })
          },
          fail: (err) => {
              console.log(err)
          }
        })
    }
})
  

总结

云函数的使用是极其灵活的,这里演示了如何利用云函数实现微信支付的调用,通过这种方式,提高了支付的安全性,也展示了基于MemFire BaaS做应用开发的更多可能。