具体代码见以上链接,下面主要分析小程序支付实现流程及数据处理。
小程序支付,涉及三个对象:
a:微信小程序
b:商户系统
c:微信后台
流程:
1、商户系统调用《统一下单api》,从微信后台得到预付编号(prepay_id)
2、微信小程序从商户系统取得prepay_id等参数,调用 wx.requestPayment(),转到用户付款操作
3、微信后台向商户系统发送支付结果通知
具体:
这里,有两处要与微信支付后台交互,一个是《统一下单》,另一个是《支付结果通知》,前者是主动请求,后者是微信支付后台在用户完成支付后,请求统一下单时提交的 notify_url 值,主动以 HTTP POST 方式发送数据到商户系统的 uri。
与微信支付后台的 request, response,数据格式都为 xml,字符集为 utf-8,因此发送时,需设置http request header 以下两个值:
Header.Set("Accept", "application/xml")
Header.Set("Content-Type", "application/xml;charset=utf-8")
关于数据内容,为了保证通讯的安全性,必须要对数据进行验证。
微信支付采用的方法是,将所有交互数据合并为一个以 key1=value1&key2=value2...的字符串(要按key的字面符号进行升序排列),再加上一个只保存在商户系统和微信支付后台的 api_key,最后也就是:key1=value1&key2=value2...&key=api_key。
然后对这个字符串,进行指定的 hash 运算,生成此文本的特征码。hash 算法有 MD5,HMAC-SHA256,默认是 MD5,如果使用SHA256,要确认相关 API 是否支持(似乎有个别 api 尚不支持 SHA256 hash 算法)
这个 hash 字串,称为 signature,在 xml 数据中,键名是 "sign",hash 算法的键名是 "sign_type"。与数字证书类比的话,这个 signature 可以打比方说是 “公钥”,而在商户支付系统里申请的 api_key 可以比方成 “私钥”(这个比喻并不准确)。这个 signature 是在网上进行 http post 发送来发送去的,每次都会根据不同的发送参数,重新进行计算生成,虽然是用了 SSL 对 http 通讯进行了再次加密,但也不能保证完全安全,因此,有这个 api_key 私钥的存在,进一步加强了安全性。由此可见,这个 api_key 很重要,绝对不能泄露,绝对不能在任何 http 通讯中被传输使用,不然就有可能被不良的第三方伪造支付结果通知,结果收到支付通知,而实际上却没有发生支付动作,从而被骗取交易。
需要注意,在生成 signature 时,不能遗漏任何一个非空值的字段,不然最终 hash 的源文本不同,生成的结果自然不一致。比方说,你发送了10个字段给服务器,但只按9个字段来生成 sign,但微信支付后台收到你的 10个字段以及你生成的 sign 后,会用这10个字段来生成 sign,再进行验证,这时结果当然是不一致的。反之,微信支付后台发送给你的也一样方式,由商户系统验证。这里,除了 sign 这个字段,其它所有参与 http post 的字段,都要加入到 hash 计算中来,包括 sign_type(如果采用默认 MD5时,不发送或留空除外)。
关于 golang xml 的解析与生成,有现成的 xml.Marshal 和 xml.Unmarshal 可用。默认时,xml 根节点的 node 名称是 struct 的 type 名称,而微信支付后台要求的根节点名称为 xml,因此要在 struct 中添加 xml.Name, 如:
type UnifyOrderRequest struct {
XMLName xml.Name `xml:"xml"`
AppID string `xml:"appid"`
...
}
另外,在收到支付结果通知时,如果消费者使用了 代金券,会产生N条自增长的字段,如 coupon_id_0, coupon_id_1。这时,golang 的 xml.Unmarshal 似乎不能应对这种情况,只好以手工补丁方式,追加这部分内容的识别。当前的方式是使用 regexp 来提取 "<coupon_id_...><![CDATA[xxxxx]]></coupon_id_...>",再用字符串替换方式提取 xxxxx 这部分的最终结果值。代码:
// PaymentNoticeCallbackRequest data struct of transaction result which from the Wechat payment system after payment completed
type PaymentNoticeCallbackRequest struct {
AppID string `xml:"appid"`
Attach string `xml:"attach"`
BankType string `xml:"bank_type"`
CashFee int `xml:"cash_fee"`
CashFeeType string `xml:"cash_fee_type"`
CouponCount int `xml:"coupon_count"`
CouponFee string `xml:"coupon_fee"`
CouponFees string `xml:"coupon_fee_0"`
CouponIDs string `xml:"coupon_id_0"`
CouponTypes string `xml:"coupon_type_0"`
...
}
// get coupon message
if req.CouponCount > 0 {
ss := string(s)
req.CouponIDs = getCouponValues(ss, "coupon_id", req.CouponCount)
req.CouponTypes = getCouponValues(ss, "coupon_type", req.CouponCount)
req.CouponFees = getCouponValues(ss, "coupon_fee", req.CouponCount)
}
// getCouponValues find coupon values then return as "v0"
func getCouponValues(s, key string, l int) string {
re := regexp.MustCompile(fmt.Sprintf(`<%s_(\d+)>(.*)</%s_\d+>`, key, key))
as := re.FindAllStringSubmatch(s, -1)
if len(as) != l {
panic("Length not match")
}
val := make([]string, l, l)
for _, a := range as {
i, _ := strconv.Atoi(a[1])
// use string replace because maybe one day
// the wechat payment system change to number type without tag "<![DATA[]]>"
val[i] = strings.Replace(strings.Replace(a[2], "<![CDATA[", "", -1), "]]>", "", -1)
}
var res string
for i, v := range val {
if i == 0 {
res += v
} else {
res += fmt.Sprintf("&%s_%d=%s", key, i, v)
}
}
return res
}
这样一来,就可以在生成 signature 时,包含了微信支付后台 http post 过来的全部字段。
最后提一下 CalculateSignature() 函数,我在这里用的是 interface{} 参数,相比其它人用 map[string]string,key 的名称全部在 struct 中,通过 xml tag 来定义,以避免调用时,由于手误给 map[string]string 指定错误的 key。如此一来,就需要告诉 CalculateSignature() 哪些 field 是不要参与 hash 计算的,在最后的参数 excludeKeys 数组进行指定。
如果要单独在其它场合迁移使用 CalculateSignature(),注意要多加一个参数 apiKey,示例中使用了全局的 apiKey。