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做应用开发的更多可能。