一、前言
以往网上支付都是支付宝的天下,随着微信用户群的日益增多(其实,到现在我也不理解微信为嘛那么火,功能还没QQ强大,或许是公众号的原因?),先如今不上个微信支付你都不好意思说你系统支持在线支付。在之前研究过Nopcommerc支付宝支付插件,对这类第三方支付流程心中大概明白,但实际开发下来发现微信支付当中需要的注意点还不少。总体上,微信支付文档不及支付宝文档,按照后者demo一步步就能完成支付功能。
二、微信支付流程
微信支持以四种方式完成支付,分别是刷卡支付、公众号支付、扫码支付、APP支付。不同支付方式功能实现存在一定差异,具体体现在请求参数、回传参数及商户系统与微信支付中心交互流程等。本文仅研究扫码支付,并且仅介绍扫码支付中的模式二(以下简称模式二)。
下图是微信官方给出的模式二支付流程,详细描述可以参见(https://pay.weixin.qq.com/wiki/doc/api/native.php?chapter=6_5)。
可以将微信支付流程描述如下:
1、准备工作。模式二支付,开发者需要事先申请公众号,申请通过后的公众号还需要申请开通微信支付,具体开通流程不知道的可以自行百度。微信支付开通成功后会得到三个参数:AppId(公众帐号ID)、MchId(商户号)、Key(用于签名)。
2、组织请求参数。与支付宝支付一样,商户系统需要将支付相关信息(如订单号、支付金额、回调地址等参数发送到微信支付系统),因此这一步需要结合微信官方文档,根据自己系统组织相关参数的获取办法。详细的请求参数参看官方文档,本文中使用的请求参数列举在下表中。
参数名称 | 参数描述 | 注意事项 |
appid | 公众帐号ID | 必须 |
MchId | 商户号 | 必须 |
Key | 参与签名生成 | 必须 |
out_trade_no | 订单号 | 必须。商户系统订单号。 |
total_fee | 支付总金额 | 必须。单位为人民币最小单位分 |
trade_type | 交易类型 |
必须。可能取值为字符串 JSAPI,NATIVE,APP,对应不同的支付模式。 JSAPI--公众号支付、NATIVE--原生扫码支付、APP--app;刷卡支付交易类型为MICROPAY,但与其它支付方式调用接口不一致,所以不讨论 |
notify_url | 通知地址 | 必须。该地址为商户系统响应微信支付系统回传支付结果的Action地址,不能为本地地址(如127.0.0.1),不能携带参数。微信会重复向该地址发送支付结果,直到商户系统给微信支付系统发送“已收到”信息后才停止发送。 |
nonce_str | 随机字符串 | 必须。不长于32位的字符串,用于参与签名的生成。 |
spbill_create_ip | 终端IP | 必须。NATIVE支付方式就是调用微信支付API的终端IP。 |
body | 商品简单描述 | 必须。官方说该字段须严格按照规范传递,但实测不按官方规范上传也没事。该字段会显示在用户支付界面上,建议还是规范书写。 |
time_start | 交易起始时间 | 不必须。一般与订单过期时间一起使用,表明本次请求返回的二维码的支付有效时间。 |
time_expire | 交易结束时间 | 不必须。失效时间必须大于五分钟。 |
sign | 签名 | 必须。签名参数,具体生成方法见官网https://pay.weixin.qq.com/wiki/doc/api/native.php?chapter=4_3 |
3、发送请求参数。向微信远程相应接口地址https://api.mch.weixin.qq.com/pay/unifiedorder 发送支付请求,该地址会返回针对当前请求的相应信息,具体返回信息参见官方文档。我们需要从中提取出二维码链接,生成二维码图片推送给用户。
4. 当用户扫码完成(与商户系统无关,客户在个人手机的微信端完成操作)微信支付系统会给notify_url设定的地址发送支付结果,商户系统接收到微信回复的信息后 更新系统的订单支付状态信息,同时给微信回馈:“我晓得喽,支付完成,不要再发了”。
5、重定向操作,将用户导向至某一界面,告知支付结果并提示下一步操作。
三 功能实现
1、请求参数的准备。
//响应的远程url地址 string url = "https://api.mch.weixin.qq.com/pay/unifiedorder"; string appid = _WeiXinPaymentSettings.AppId;//录入自己申请的公众号ID string mch_Id = _WeiXinPaymentSettings.MchId; string key = _WeiXinPaymentSettings.Key; //商户订单号 string out_trade_no = postProcessPaymentRequest.Order.Id.ToString() +"PAY"+ DateTime.Now.ToString("yyyyMMddHHmmss");//发现支付订单号为一位数字时,回返回请求失败信息,所以加入时间字符串,字符串PAY为split出实际订单号做准备 当然此处可以根据自身系统设计 //支付总金额 string total_fee = (postProcessPaymentRequest.Order.OrderTotal*100).ToString("0", CultureInfo.InvariantCulture); //调用微信API的IP string spbill_create_ip = _webHelper.GetCurrentIpAddress(); //交易类型 string trade_type = "NATIVE"; //产品Id 这里使用订单号 string product_id = out_trade_no; //接收微信支付异步通知回调地址 因为是开发Nop插件,所以此处为插件响应请求的地址 string notify_url = _webHelper.GetStoreLocation(false) + "Plugins/PaymentWeiXinPay/Notify"; //商品或支付单简要描述 string body = "TEST缴费"; //随机数字符串 string nonce_str = WXPayUlities.CreateRandomStr(); //订单(指代微信订单)生成时间 var now = DateTime.Now; string time_start = DateTime.Now.ToString("yyyyMMddHHmmss"); //string time_start = "20160503114458"; //订单失效时间 此处为开始时间后五分钟 string time_expire = now.AddMinutes(5).ToString("yyyyMMddHHmmss"); //string time_expire = "20160503114957";
静态类WXPayUlities的CreateRandomStr()方法用于生成随机字符串:
public static string CreateRandomStr(int length = 0) { var guid = Guid.NewGuid(); if (length <= 0 || length > 32) { return guid.ToString("N"); } else { return guid.ToString("N").Remove(0, length); } }
2、组织请求参数
向微信支付系统发送的支付请求按key-value的形式组织,并且要求所有非空参数值的参数按照参数名ASCII码从小到大排序。这里使用C#的SortedDictionary类型对参数进行排序。
var res = new SortedDictionary<string, object>(); res.SetValue("appid", appid); res.SetValue("mch_id", mch_Id); res.SetValue("out_trade_no", out_trade_no); res.SetValue("total_fee", total_fee); res.SetValue("spbill_create_ip", spbill_create_ip); res.SetValue("trade_type", trade_type); res.SetValue("product_id", product_id); res.SetValue("notify_url", notify_url); res.SetValue("body", body); res.SetValue("nonce_str", nonce_str); res.SetValue("time_start", time_start); res.SetValue("time_expire", time_expire);
签名生成的通用步骤如下:
第一步,设所有发送或者接收到的数据为集合M,将集合M内非空参数值的参数按照参数名ASCII码从小到大排序(字典序),使用URL键值对的格式(即key1=value1&key2=value2…)拼接成字符串stringA。
特别注意以下重要规则:
◆ 参数名ASCII码从小到大排序(字典序);
◆ 如果参数的值为空不参与签名;
◆ 参数名区分大小写;
◆ 验证调用返回或微信主动通知签名时,传送的sign参数不参与签名,将生成的签名与该sign值作校验。
◆ 微信接口可能增加字段,验证签名时必须支持增加的扩展字段
第二步,在stringA最后拼接上key得到stringSignTemp字符串,并对stringSignTemp进行MD5运算,再将得到的字符串所有字符转换为大写,得到sign值signValue。
key设置路径:微信商户平台(pay.weixin.qq.com)-->账户设置-->API安全-->密钥设置
举例:
假设传送的参数如下:
appid: wxd930ea5d5a258f4f
mch_id: 10000100
device_info: 1000
body: test
nonce_str: ibuaiVcKdpRxkhJA
第一步:对参数按照key=value的格式,并按照参数名ASCII字典序排序如下:
stringA="appid=wxd930ea5d5a258f4f&body=test&device_info=1000&mch_id=10000100&nonce_str=ibuaiVcKdpRxkhJA";
第二步:拼接API密钥:
stringSignTemp="stringA&key=192006250b4c09247ec02edce69f6a2d"
sign=MD5(stringSignTemp).toUpperCase()="9A0A8659F005D6984697E2CA0A9CF3B7"
最终得到最终发送的数据:
<xml>
<appid>wxd930ea5d5a258f4f</appid>
<mch_id>10000100</mch_id>
<device_info>1000<device_info>
<body>test</body>
<nonce_str>ibuaiVcKdpRxkhJA</nonce_str>
<sign>9A0A8659F005D6984697E2CA0A9CF3B7</sign>
<xml>
因此可以通过一个拓展string的拓展方法生成签名:
public static string CreateSign(this string str, string key) { string t = str + "&key=" + key; return GetMD5(t, "utf-8").ToUpper(); } public static string GetMD5(string Input, string Input_charset) { MD5 md5 = new MD5CryptoServiceProvider(); byte[] t = md5.ComputeHash(Encoding.GetEncoding(Input_charset).GetBytes(Input)); StringBuilder sb = new StringBuilder(32); for (int i = 0; i < t.Length; i++) { sb.Append(t[i].ToString("x").PadLeft(2, '0')); } return sb.ToString(); }
将SortedDictionary类型转化为URL key-value对
public static string ToUrl(this SortedDictionary<string, object> source) { string buff = ""; foreach (KeyValuePair<string, object> pair in source) { if (pair.Value == null) { throw new Exception("m_values内部含有值为null的字段!"); } if (pair.Key != "sign" && pair.Value.ToString() != "") { buff += pair.Key + "=" + pair.Value + "&"; } } buff = buff.Trim('&'); return buff; }
至此,我们将SortedDictionary转为为url key-value后使用商户的Key可以完成签名字符串的创建
//签名 string sign = res.ToUrl().CreateSign(key);
值得注意的是,微信支付系统与商务系统之间的交互都为XML格式,我们需要两个方法,实现XML与URL之间的互相转换
/// <summary> /// 将参数列表转化为XML /// </summary> /// <param name="m_values"></param> /// <returns></returns> public static string ToXml(this SortedDictionary<string, object> m_values) { //数据为空时不能转化为xml格式 if (0 == m_values.Count) { throw new Exception("m_values数据为空!"); } string xml = "<xml>"; foreach (KeyValuePair<string, object> pair in m_values) { //字段值不能为null,会影响后续流程 if (pair.Value == null) { throw new Exception("m_values内部含有值为null的字段!"); } if (pair.Value.GetType() == typeof(int)) { xml += "<" + pair.Key + ">" + pair.Value + "</" + pair.Key + ">"; } else if (pair.Value.GetType() == typeof(string)) { xml += "<" + pair.Key + ">" + "<![CDATA[" + pair.Value + "]]></" + pair.Key + ">"; } else//除了string和int类型不能含有其他数据类型 { throw new Exception("m_values字段数据类型错误!"); } } xml += "</xml>"; return xml; } /// <summary> /// 将xml转为WxPayData对象并返回对象内部的数据 /// </summary> /// <param name="xml">待提取的XML</param> /// <param name="locolkey">本地Key</param> /// <returns></returns> public static SortedDictionary<string, object> FromXml(this string xml, string locolkey) { var values = new SortedDictionary<string, object>(); if (string.IsNullOrEmpty(xml)) { throw new Exception("将空的xml串转换为WxPayData不合法!"); } XmlDocument xmlDoc = new XmlDocument(); xmlDoc.LoadXml(xml); XmlNode xmlNode = xmlDoc.FirstChild;//获取到根节点<xml> XmlNodeList nodes = xmlNode.ChildNodes; foreach (XmlNode xn in nodes) { XmlElement xe = (XmlElement)xn; values[xe.Name] = xe.InnerText;//获取xml的键值对到WxPayData内部的数据中 } try { if ((string)values["return_code"] != "SUCCESS") { return values; } } catch (Exception ex) { throw new Exception(ex.Message); } return values; }
3 、发送支付请求。
我们通过C#的HttpWebRequest类向微信支付接口发送POST的支付请求。这里就不贴实现代码了,需要注意网络延迟,请求的超市处理。
4、处理请求结果
这一步,我们需要从微信返回的XML中获取微信返回的二维码链接
//向微信发送Post请求并得到二维码链接 string response = HttpService.Post(xml, url, 6); //回传数据为XML结构 提取出二维码生成链接 var p = response.FromXml(key); //返回结果判断 必须为return_code=SUCCESS 结合自己业务情况处理 //接受微信返回的签名 并本地生成签名完成校验! //验证签名 var t=p.CheckSign(key); /// <summary> /// 检查签名是否匹配 /// </summary> /// <param name="values">接收到的回传参数</param> /// <returns>错误抛出异常,正确返回true</returns> public static bool CheckSign(this SortedDictionary<string, object> values, string key) { //如果没有设置签名,则跳过检测 if (!IsSet(values, "sign")) { throw new Exception("签名存在但不合法!"); } //如果设置了签名但是签名为空,则抛异常 else if (GetValue(values, "sign") == null || GetValue(values, "sign").ToString() == "") { throw new Exception("签名存在但不合法!"); } //获取接收到的签名 string return_sign = GetValue(values, "sign").ToString(); //在本地计算新的签名 string cal_sign = CreateSign(ToUrl(values), key); if (cal_sign == return_sign) { return true; } throw new Exception("签名验证错误!"); }
经过必要的验证后,我们可以提取二维码链接并生成二维码图片。这里引用了程序集ThoughtWorks.QRCode.dll 来生成二维码图片
//提取二维码链接 var code_Url = p.GetValue("code_url").ToString(); //得到二维码图片的本地路径 var v = CreateQRCode(code_Url); /// <summary> /// 生成二维码 /// </summary> /// <param name="result_Code">微信返回的url</param> /// <returns>返回二维码图片地址</returns> protected string CreateQRCode(string result_Code) { QRCodeEncoder endocder = new QRCodeEncoder(); //二维码背景颜色 endocder.QRCodeBackgroundColor = System.Drawing.Color.White; //二维码编码方式 endocder.QRCodeEncodeMode = QRCodeEncoder.ENCODE_MODE.BYTE; //每个小方格的宽度 endocder.QRCodeScale = 10; //二维码版本号 endocder.QRCodeVersion = 5; //纠错等级 endocder.QRCodeErrorCorrect = QRCodeEncoder.ERROR_CORRECTION.M; //将json川做成二维码 Bitmap bitmap = endocder.Encode(result_Code); //图片存储路径 string strSaveDir = _webHelper.MapPath("path"); if (!Directory.Exists(strSaveDir)) { Directory.CreateDirectory(strSaveDir); } var randomId = Guid.NewGuid(); string strSavePath = Path.Combine(strSaveDir, randomId + ".png"); if (!System.IO.File.Exists(strSavePath)) { bitmap.Save(strSavePath); return Path.Combine("path", randomId + ".png"); } else { throw new NopException("存在同名的微信支付二维码"); } }
至此,微信支付流程完成了大半,当用户扫码完成支付后,我们的回调地址对应的方法需要结合业务需要作出如订单状态修改为已支付等相应处理,这里不再贴代码。但值得一提的是,为了安全起见,回调地址中有必要加上回传订单的查询。
//查询订单 protected bool QueryOrder(string transaction_id) { var res = new SortedDictionary<string, object>(); res.SetValue("transaction_id", transaction_id); var result = OrderQuery(res); if (result.GetValue("return_code").ToString() == "SUCCESS" && result.GetValue("result_code").ToString() == "SUCCESS") { return true; } else { return false; } } public SortedDictionary<string, object> OrderQuery(SortedDictionary<string, object> inputObj, int timeOut = 6) { string url = "https://api.mch.weixin.qq.com/pay/orderquery"; //检测必填参数 if (!inputObj.IsSet("out_trade_no") && !inputObj.IsSet("transaction_id")) { throw new Exception("订单查询接口中,out_trade_no、transaction_id至少填一个!"); } inputObj.SetValue("appid", _WeiXinPaymentSettings.AppId);//公众账号ID inputObj.SetValue("mch_id", _WeiXinPaymentSettings.MchId);//商户号 inputObj.SetValue("nonce_str", WXPayUlities.CreateRandomStr());//随机字符串 inputObj.SetValue("sign", inputObj.ToUrl().CreateSign(_WeiXinPaymentSettings.Key));//签名 string xml = inputObj.ToXml(); string response = HttpService.Post(xml, url, timeOut);//调用HTTP通信接口提交数据 var responseSorted = response.FromXml(_WeiXinPaymentSettings.Key); var t = responseSorted.CheckSign(_WeiXinPaymentSettings.Key); if (t == false) throw new Exception("回传签名验证失败"); return responseSorted; }