wechatpay

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

通过MemFire BaaS将原本后端的业务逻辑迁移到前端完成,的确会提高效率,但也不可避免的带来了一些安全隐患。

背景

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

业务分析

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

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

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

数据表设计

  • 用户表:无需设计,BaaS服务自带
  • 书籍表:books

云函数

  • 我们将用户下单这个操作形成的结果叫做“创建交易”。交易表:transactions

云函数

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

支付接口实现

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

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

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

云函数

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

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

1 const { v4: uuidv4 } = require('uuid');
2 const WxPay = require('wechatpay-node-v3')
3 const sup = require('@supabase/supabase-js')
4 
5 // 支付客户端和supabase客户端
6 let pay
7 let supabase
8 
9 // 云函数生命周期,初始化时,初始化supabase和pay
10 exports.initializer = (context, callback) => {
11     const publicKeyPem = process.env.publicKey
12     const privateKeyPem = process.env.privateKey
13 
14     try {
15 
16         // 初始化支付客户端
17         pay = new WxPay({
18           appid: process.env.appId,
19           mchid: process.env.mchId,
20           publicKey: formatPublicKey(publicKeyPem),
21           privateKey: formatPrivateKey(privateKeyPem),
22         })
23 
24         // 初始化supabase客户端
25         supabase = sup.createClient(process.env.API_URL, process.env.SERVICE_ROLE_KEY)
26 
27     } catch (e) {
28 
29         console.log('initializing failed')
30         callback(e)
31 
32     }
33 
34     // 如果不执行如下行,会导致无法退出initialize
35     callback(null, 'successful');
36 
37 }
38 
39 // 云函数入口文件
40 exports.handler = async (req, resp, context) => {
41 
42     // 解决跨域问题
43     resp.setHeader('Access-Control-Allow-Origin', '*') // *可以改成你的服务域名
44     resp.setHeader('Access-Control-Allow-Methods', '*');
45     resp.setHeader('Access-Control-Allow-Headers', '*');
46     resp.setHeader('Access-Control-Max-Age', '3600');
47 
48     /** 请求参数
49      * method: 请求方法
50      * queries: 请求参数
51      * headers: 请求头
52      * body: 请求体, 为 Buffer 类型
53    */
54     const { method, queries, headers, body } = req
55 
56     const { action } = queries
57 
58     // 定义用户的id
59     let userId
60 
61     // OPTIONS的时候不检查
62     if (req.method !== 'OPTIONS' && !headers['wechatpay-timestamp']) {
63 
64         // 利用用户的令牌创建一个匿名的supabase client
65         const anonClient = sup.createClient(
66             process.env.API_URL,
67             process.env.ANON_KEY,
68             { global: { headers: { Authorization: req.headers.authorization }}}
69             )
70             // 获取请求用户信息
71             const { data: { user }, error} = await anonClient.auth.getUser();
72 
73             // 获取用户错误,说明token无效
74             if (error) {
75             resp.setStatusCode(401)
76             resp.setHeader('Content-Type', 'application/json')
77             resp.send(JSON.stringify({code: 401, msg: 'forbidden'}))
78             }
79 
80             // 请求中绑定用户的id
81             userId = user.id
82     }
83 
84     // 解决跨域问题
85     if (req.method === 'OPTIONS') {
86         resp.setStatusCode(204)
87         resp.send('');
88     } else if (method === 'GET' && action === 'prepay') {
89 
90         // 获取请求参数
91         const {  tradeId, openId } = queries
92 
93         const { data, error} = await supabase
94             .from('transactions')
95             .select('books')
96             .eq('id', tradeId)
97             .eq('uid', userId) // 这里就限制了用户只能操作自己的订单
98             .single()
99 
100         const bookIds = data.books.map(item => item.id)
101       if (error) {
102         // 返回响应
103         resp.setStatusCode(403)
104         resp.setHeader('Content-Type', 'application/json')
105         resp.send(JSON.stringify({
106           status: 403
107         }))
108       }
109 
110       const { data: books, error: err } = await supabase
111         .from('books')
112         .select('price')
113         .in('id', bookIds)
114 
115         const amount = books.reduce((sum, book) => sum + book.price, 0)
116         const description = '发起支付时间-' + new Date().toISOString()
117         const notifyUrl = process.env.notifyUrl // 通过环境变量传进来
118 
119         // 下单
120         const tradeNo = convertTradeID(tradeId)
121         const result = await getOrder(description, tradeNo, notifyUrl, amount, openId)
122 
123         resp.setStatusCode(result.status)
124         resp.setHeader('Content-Type', 'application/json')
125         resp.send(JSON.stringify(result))
126 
127     } else if (method === 'POST' && headers['wechatpay-timestamp']) {
128 
129         let isSuccessed = false
130         let tradeId = ''
131 
132         try {
133             const result = JSON.parse(req.body.toString())
134 
135             const { ciphertext, associated_data, nonce } = result.resource
136 
137             const decodeContent = pay.decipher_gcm(ciphertext, associated_data, nonce, process.env.apiKey)
138 
139             tradeId = str2UUID(decodeContent.out_trade_no)
140             console.log(decodeContent.trade_state)
141             console.log(tradeId)
142 
143             const { data, error } = await supabase.from('transactions').update({ status: decodeContent.trade_state }).eq('id', tradeId)
144 
145         } catch (err) {
146 
147             resp.setStatusCode(500)
148             resp.setHeader('Content-Type', 'application/json')
149             resp.send(JSON.stringify({
150                 code: 'FAIL',
151                 message: '失败'
152             }))
153         }
154 
155         resp.setStatusCode(200)
156         resp.setHeader('Content-Type', 'application/json')
157         resp.send(JSON.stringify({
158             code: 'SUCCESS',
159         }))
160 
161     } else if (method === 'GET' && action === 'querypay') {
162         // 获取请求参数
163         const { tradeId, tag } = queries
164 
165         // 查询订单
166         const tradeNo = convertTradeID(tradeId)
167         const result = await queryOrder(tradeNo, tag)
168 
169         // 返回响应
170         resp.setStatusCode(result.status)
171         resp.setHeader('Content-Type', 'application/json')
172         resp.send(JSON.stringify(result))
173 
174     } else if (method === 'GET' && action === 'closepay') {
175         // 获取请求参数
176         const { tradeId } = queries
177 
178         // 关闭订单
179         const tradeNo = convertTradeID(tradeId)
180         const result = await closeOrder(tradeNo)
181 
182         // 标记数据库交易状态
183         await supabase.from('transactions').update({ status: 'CLOSED' }).eq('id', tradeId)
184 
185         // 返回响应
186         resp.setStatusCode(result.status)
187         resp.setHeader('Content-Type', 'application/json')
188         resp.send(JSON.stringify(result))
189     }
190 
191     // ---------------------------------------- start 退款流程: 按需开启 -------------------------
192     // 申请退款
193     else if (method === 'GET' && action === 'refund') {
194         // 获取请求参数
195         const { tradeId, refund } = queries
196 
197         // const refundId = 'refundId' // 退款单号, 需要自己生成
198         const { data, error} = await supabase
199         .from('transactions')
200         .select('books')
201         .eq('id', tradeId)
202         .eq('uid', userId) // 这里就限制了用户只能操作自己的订单
203         .single()
204 
205         if (error) {
206             resp.setStatusCode(403)
207             resp.setHeader('Content-Type', 'application/json')
208             resp.send(JSON.stringify({
209               status: 403
210             }))
211         }
212 
213         const total = data.books.reduce((sum, i) => i.price + sum, 0)
214         const refundId = uuidv4()
215 
216         // 退款
217         const tradeNo = convertTradeID(tradeId)
218         const result = await refunds(tradeNo, refundId, Number(refund), total)
219 
220         await supabase.from('refunds').insert({ id: refundId, transaction_id: tradeId, status: 1, refund: refund, reason: '退款'})
221 
222         // 返回响应
223         resp.setStatusCode(200)
224         resp.setHeader('Content-Type', 'application/json')
225         resp.send(JSON.stringify(result))
226 
227     // 查询退款
228     } else if (method === 'GET' && action === 'queryrefund') {
229         // 获取请求参数
230         const { refundId } = queries
231 
232         // 查询退款
233         const result = await queryRefund(refundId)
234 
235         // 返回响应
236         resp.setStatusCode(result.status)
237         resp.setHeader('Content-Type', 'application/json')
238         resp.send(JSON.stringify(result))
239 
240     // 其他请求,均返回错误消息
241     }
242     // ---------------------------------------- end 退款流程 -------------------------
243     else {
244         resp.setStatusCode(200)
245         resp.setHeader('Content-Type', 'application/json')
246         resp.send(JSON.stringify({
247             code: 404,
248             msg: 'not found'
249         }))
250     }
251 
252 }
253 
254 // 创建于支付订单,支付链接(native模式可以将其生成二维码供用户扫码支付),传入openId,则是小程序支付
255 async function getOrder(description, tradeNo, notifyUrl, amount, openId = undefined) {
256     const params = {
257         description,
258         out_trade_no: tradeNo,
259         notify_url: notifyUrl,
260         amount: {
261             total: amount
262         }
263     }
264 
265     let result
266 
267     if (openId) {
268         result = await pay.transactions_jsapi({...params, payer: { openid: openId }})
269     } else {
270         result = await pay.transactions_native(params)
271     }
272 
273     console.log(result)
274     return result
275 
276 }
277 
278 // 查询订单,通过商户订单号查询或者微信订单号查询,通过tag区分,默认通过订单号查询
279 async function queryOrder(tradeNo) {
280     const result = await pay.query({out_trade_no: tradeNo })
281 
282     console.log(result)
283     return result
284 
285 }
286 
287 // 关闭订单
288 async function closeOrder(tradeNo) {
289     const result = await pay.close(tradeNo)
290 
291     console.log(result)
292     return result
293 
294 }
295 
296 // 申请退款
297 async function refunds(tradeNo, refundId, refund, total) {
298     const params = {
299         out_trade_no: tradeNo,
300         out_refund_no: refundId, // 自己传一个进来,存起来,后续用来查
301         reason: 'refund',
302         amount: {
303             refund,
304             total,
305             currency: 'CNY'
306         }
307     }
308 
309     const result = await pay.refunds(params)
310 
311     console.log(result)
312     return result
313 
314 }
315 
316 // 查询单笔退款
317 async function queryRefund(refundId) {
318     const result = await pay.find_refunds(refundId)
319 
320     console.log(result)
321     return result
322 
323 }
324 
325 function convertTradeID(tradeId) {
326     const regex = /-/g
327     return tradeId.replace(regex, '')
328 }
329 
330 function str2UUID(str) {
331     if (str.length !== 32) {
332       throw new Error('Input string must be 32 characters long.');
333     }
334 
335     const uuid = [
336       str.slice(0, 8),
337       str.slice(8, 12),
338       str.slice(12, 16),
339       str.slice(16, 20),
340         str.slice(20),
341     ].join('-');
342 
343     return uuid;
344 }
345 
346 function formatPublicKey(rawPublicKey) {
347   const keyHeader = '-----BEGIN CERTIFICATE-----';
348   const keyFooter = '-----END CERTIFICATE-----';
349 
350     const regex = /\s/g;
351   const str = rawPublicKey.replace(regex, '')
352   // 按64个字符一行分割密钥
353   const formattedKey = str.match(/.{1,64}/g).join('\n');
354 
355   return `${keyHeader}\n${formattedKey}\n${keyFooter}`;
356 }
357 
358 function formatPrivateKey(rawPrivateKey) {
359   const keyHeader = '-----BEGIN PRIVATE KEY-----';
360   const keyFooter = '-----END PRIVATE KEY-----';
361 
362   const regex = /\s/g;
363   const str = rawPrivateKey.replace(regex, '')
364   // 按64个字符一行分割密钥
365   const formattedKey = str.match(/.{1,64}/g).join('\n');
366 
367   return `${keyHeader}\n${formattedKey}\n${keyFooter}`;
368 }
369 
370 // 申请交易账单 date的格式是yyyy-MM-dd
371 // async function applyTradeBill(date) {
372 //     const result = await pay.tradebill({
373 //         bill_date: date,
374 //         bill_type: 'ALL'
375 //     })
376 //
377 //     console.log(result)
378 //     return result
379 //
380 // }
381 
382 // 申请资金账单
383 // async function applyFundBill(date) {
384 //     const result = await pay.fundflowbill({
385 //         bill_date: date,
386 //         account_type: 'BASIC'
387 //     })
388 //
389 //     console.log(result)
390 //     return result
391 //
392 // }
393 
394 // 下载账单, 这个url是上两个接口返回的
395 // async function downloadBill(url) {
396 //     const result = await pay.downloadbill(url)
397 //
398 //     console.log(result)
399 //     return result
400 //
401 // }

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

  • 云函数一旦部署,会生成一个可调用的url给用户,但是如何保证请求该URL时,接口不会报跨域错误:
1          // 解决跨域问题
2          resp.setHeader('Access-Control-Allow-Origin', '*') // *可以改成你的服务域名
3          resp.setHeader('Access-Control-Allow-Methods', '*');
4          resp.setHeader('Access-Control-Allow-Headers', '*');
5          resp.setHeader('Access-Control-Max-Age', '3600');
6      ```
7
8然后当接口遇到options请求方法时,返回204
9
10 - 云函数是如何判断用户的?通过请求头来获取用户信息
11```JavaScript
12      // 利用用户的令牌创建一个匿名的supabase client
13      const anonClient = sup.createClient(
14          process.env.API_URL,
15          process.env.ANON_KEY,
16          { global: { headers: { Authorization: req.headers.authorization }}}
17          )
18          // 获取请求用户信息
19          const { data: { user }, error} = await anonClient.auth.getUser();
20      ```
21  - 云函数怎么判断支付金额?通过查询交易ID得到书籍ID,然后通过书籍ID得到了金额,并进行了加总。这样就避免了前端窜改金额。
22
23```JavaScript
24  const { data, error} = await supabase
25      .from('transactions')
26      .select('books')
27      .eq('id', tradeId)
28      .eq('uid', userId) // 这里就限制了用户只能操作自己的订单
29      .single()
30  
31  const bookIds = data.books.map(item => item.id)
32  if (error) {
33      // 返回响应
34      resp.setStatusCode(403)
35      resp.setHeader('Content-Type', 'application/json')
36      resp.send(JSON.stringify({
37        status: 403
38      }))
39  }
40  
41  const { data: books, error: err } = await supabase
42  .from('books')
43  .select('price')
44  .in('id', bookIds)
45  
46  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和密钥,将其填写在服务上这里即可。

云函数

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

1// index.js
2import { supabase } from '../../lib/supabase'
3
4// 获取应用实例
5const app = getApp()
6
7Page({
8  data: {},
9
10  onLoad() {
11    if (wx.getUserProfile) {
12      this.setData({
13        canIUseGetUserProfile: true
14      })
15    }
16  },
17
18  login() {
19    wx.login({
20        success: async res => {
21            // 通过这一行代码,即可实现微信认证,着实方便
22            const { data, error } = await supabase.auth.signInWithWechat({ code: res.code })
23
24            if (error) {
25                wx.showToast({
26                  title: '微信认证失败',
27                  icon: 'none',
28                  duration: 2000
29                })
30            } else if (data) {
31                wx.navigateTo({
32                  url: '/pages/books/index',
33                })
34            }
35        },
36        fail: (err) => {
37            wx.showToast({
38              title: err.errMsg,
39              icon: 'none',
40              duration: 2000
41            })
42        }
43    })
44  }
45})

图书选择实现

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

1import { supabase } from "../../lib/supabase"
2
3// pages/books/index.js
4Page({
5
6    /**
7     * 页面的初始数据
8     */
9    data: {
10        books: [],
11        bookIds: [],
12        selectedBooks: []
13    },
14
15    /**
16     * 生命周期函数--监听页面加载
17     */
18    async onLoad(options) {
19        const { data, error } = await supabase.from('books').select('*')
20        const bookIds = data.data.map((i) => i.id)
21        this.setData({ books: data.data, bookIds: bookIds })
22
23    },
24
25    onChange(e) {
26        this.setData({ selectedBooks: e.detail.value })
27    },
28
29    async goToPay() {
30
31        const uid = JSON.parse(wx.getStorageSync('sb-cgj3qoi5g6h9k9li2g30-auth-token')).user.id
32
33        const goods = this.data.books.filter(item => this.data.selectedBooks.indexOf(item.id) > -1)
34
35        const { data, error } = await supabase.from('transactions')
36            .insert({ uid, books: goods, updated_at: new Date().toISOString() })
37            .select()
38            .single()
39
40        if (error) {
41            wx.showToast({
42              title: '暂时无法创建订单',
43            })
44        } else {
45            wx.navigateTo({
46              url: `/pages/trade/index?tradeid=${data.data.id}`
47            })
48        }
49        
50    }
51})

交易支付

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

1import { supabase } from "../../lib/supabase"
2
3// pages/trade/index.js
4Page({
5
6    /**
7     * 页面的初始数据
8     */
9    data: {
10        books: [],
11        total: 0,
12        tradeId: ''
13    },
14
15    /**
16     * 生命周期函数--监听页面加载
17     */
18    async onLoad(options) {
19        let trade_id
20
21        try {
22            trade_id = options.tradeid
23            this.setData({tradeId: trade_id})
24        } catch (error) {
25            wx.showToast({
26              title: '无法获取交易订单,请重新下单',
27            })
28
29            setTimeout(() => {
30                wx.navigateTo({
31                  url: '/pages/books/index',
32                })
33            }, 2000)
34        }
35
36        const { data, error } = await supabase.from('transactions').select('books').eq('id', trade_id).single()
37
38        if (error) {
39            wx.showToast({
40                title: '无法获取交易订单,请重新下单',
41              })
42  
43              setTimeout(() => {
44                  wx.navigateTo({
45                    url: '/pages/books/index',
46                  })
47              }, 2000)
48        }
49
50        this.setData({books: data.data.books})
51        const sum = data.data.books.reduce((sum, i) => sum + i.price, 0)
52        this.setData({total: sum/100})
53    },
54
55    async goPay() {
56        // 从本地session中获取openId信息和token信息
57        const openId = JSON.parse(wx.getStorageSync('sb-xxxx-auth-token')).user.wechat_id
58        const token = JSON.parse(wx.getStorageSync('sb-xxxx-auth-token')).access_token
59        wx.request({
60          url: 'https://your_cloud_function_url/pay',
61          method: 'GET',
62          data: {
63              openId,
64              tradeId: this.data.tradeId.replace(/-/g, ''),
65              action: 'prepay'
66          },
67          header: {
68              'Authorization': 'Bearer ' + token
69          },
70          success:  (res) => {
71              console.log(res)
72              const { timeStamp, nonceStr, signType, paySign } = res.data
73              wx.requestPayment({
74                timeStamp: timeStamp,
75                nonceStr: nonceStr,
76                package: res.data.package,
77                signType: signType,
78                paySign: paySign,
79                success (res) { 
80                    console.log(res)
81                },
82                fail (res) { 
83                    console.log(res)
84                }
85              })
86          },
87          fail: (err) => {
88              console.log(err)
89          }
90        })
91    }
92})

总结

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