JAVA微信扫码支付模式二功能实现完整例子

发布时间:2019-10-05
技术:spring 4.3.3

概述

本例子实现微信扫码支付模式二的支付功能,应用场景是,web网站微信扫码支付。实现从点击付费按钮、到弹出二维码、到用户用手机微信扫码支付、到手机上用户付费成功、web网页再自动调整到支付成功后的页面,这一个过程。

详细

一、准备工作

先开通微信公众号,再开通微信公众号里面的微信支付功能,这些是前提条件,多说一句,申请开通微信公众号需要等待审核,然后在开通微信支付功能,还得等待审核,前前后后耗时得好几天。

image.png

关于准备工作,再看看微信官方关于“微信支付”的介绍,官方地址 https://pay.weixin.qq.com/wiki/doc/api/native.php?chapter=6_1 。这个是文档的准备,大概可以理解到微信支付有哪些模式,然后大概是怎样一个东东。

然后重点看看如下几个,实际上需要准备的东西,红色花掉的部分(PayConfigUtil类里面),需要根据自己的实际情况填写:

image.png

其中APP_ID和APP_SECRET可以在公众平台找着,MCH_ID和API_KEY则在商户平台找到,特别是API_KEY要在商户平台设置好,对于“微信扫码支付模式二”(支付与回调)实际只会用到APP_ID、MCH_ID和API_KEY,其他的都不用。

二、程序实现

这里使用spring mvc做一个购买商品,微信扫码支付的演示。先项目代码截图,

image.png


以下摘取重点环节的代码说明下:

1、首先是接入微信接口,获取微信支付二维码。

package com.demodashi;

import java.util.Map;
import java.util.SortedMap;
import java.util.TreeMap;

import javax.inject.Named;

import com.demodashi.pay.util.HttpUtil;
import com.demodashi.pay.util.PayToolUtil;
import com.demodashi.pay.util.PayConfigUtil;
import com.demodashi.pay.util.XMLUtil4jdom;

@Named("userService")
public class UserServiceImpl implements UserService {
	
	@Override
	public String weixinPay(String userId, String productId) throws Exception {
		
        String out_trade_no = "" + System.currentTimeMillis(); //订单号 (调整为自己的生产逻辑)
        
        // 账号信息 
        String appid = PayConfigUtil.APP_ID;  // appid  
        //String appsecret = PayConfigUtil.APP_SECRET; // appsecret  
        String mch_id = PayConfigUtil.MCH_ID; // 商业号  
        String key = PayConfigUtil.API_KEY; // key  
        
        String currTime = PayToolUtil.getCurrTime();  
        String strTime = currTime.substring(8, currTime.length());  
        String strRandom = PayToolUtil.buildRandom(4) + "";  
        String nonce_str = strTime + strRandom;  
        
        // 获取发起电脑 ip
        String spbill_create_ip = PayConfigUtil.CREATE_IP;
        // 回调接口   
        String notify_url = PayConfigUtil.NOTIFY_URL;
        String trade_type = "NATIVE";
          
        SortedMap<Object,Object> packageParams = new TreeMap<Object,Object>();  
        packageParams.put("appid", appid);  
        packageParams.put("mch_id", mch_id);  
        packageParams.put("nonce_str", nonce_str);  
        packageParams.put("body", "可乐");  //(调整为自己的名称)
        packageParams.put("out_trade_no", out_trade_no);  
        packageParams.put("total_fee", "10"); //价格的单位为分  
        packageParams.put("spbill_create_ip", spbill_create_ip);  
        packageParams.put("notify_url", notify_url);  
        packageParams.put("trade_type", trade_type);  
  
        String sign = PayToolUtil.createSign("UTF-8", packageParams,key);  
        packageParams.put("sign", sign);
          
        String requestXML = PayToolUtil.getRequestXml(packageParams);  
        System.out.println(requestXML);  
   
        String resXml = HttpUtil.postData(PayConfigUtil.UFDODER_URL, requestXML);  
  
        Map map = XMLUtil4jdom.doXMLParse(resXml);  
        String urlCode = (String) map.get("code_url");  
        
        return urlCode;  
	}

}

以上代码会按照微信支付的协议,生成类似这样格式的URL:weixin://wxpay/bizpayurl?pr=pIxXXXX

2、根据以上方法所产生的URL生成二维码,这里采用我采用的是google的core.jar包来生成二维码

@ResponseBody
@RequestMapping("/qrcode.do")
public void qrcode(HttpServletRequest request, HttpServletResponse response,
	ModelMap modelMap) {
	try {
       String productId = request.getParameter("productId");
       String userId = "user01";
       String text = userApplication.weixinPay(userId, productId); 
       //根据url来生成生成二维码
       int width = 300; 
       int height = 300; 
       //二维码的图片格式 
       String format = "gif"; 
       Hashtable hints = new Hashtable(); 
       //内容所使用编码 
       hints.put(EncodeHintType.CHARACTER_SET, "utf-8");
       BitMatrix bitMatrix;
	    try {
		bitMatrix = new MultiFormatWriter().encode(text, BarcodeFormat.QR_CODE, width, height, hints);
		QRUtil.writeToStream(bitMatrix, format, response.getOutputStream());
	    } catch (WriterException e) {
		e.printStackTrace();
	    }
		
	} catch (Exception e) {
	}
}

上面代码中涉及到几个工具类:PayConfigUtil、PayCommonUtil、HttpUtil和XMLUtil,其中PayConfigUtil放的就是上面提到一些配置及路径,PayCommonUtil涉及到了获取当前事件、产生随机字符串、获取参数签名和拼接xml几个方法,代码如下:

package com.demodashi.pay.util;

import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
import java.util.SortedMap;

public class PayToolUtil {

	/** 
     * 是否签名正确,规则是:按参数名称a-z排序,遇到空值的参数不参加签名。 
     * @return boolean 
     */  
    public static boolean isTenpaySign(String characterEncoding, SortedMap<Object, Object> packageParams, String API_KEY) {  
        StringBuffer sb = new StringBuffer();  
        Set es = packageParams.entrySet();  
        Iterator it = es.iterator();  
        while(it.hasNext()) {  
            Map.Entry entry = (Map.Entry)it.next();  
            String k = (String)entry.getKey();  
            String v = (String)entry.getValue();  
            if(!"sign".equals(k) && null != v && !"".equals(v)) {  
                sb.append(k + "=" + v + "&");  
            }  
        }  
          
        sb.append("key=" + API_KEY);  
          
        //算出摘要  
        String mysign = MD5Util.MD5Encode(sb.toString(), characterEncoding).toLowerCase();  
        String tenpaySign = ((String)packageParams.get("sign")).toLowerCase();  
        
        //System.out.println(tenpaySign + "    " + mysign);  
        return tenpaySign.equals(mysign);  
    }  
  
    /** 
     * @author 
     * @date 2016-4-22 
     * @Description:sign签名 
     * @param characterEncoding 
     *            编码格式 
     * @param parameters 
     *            请求参数 
     * @return 
     */  
    public static String createSign(String characterEncoding, SortedMap<Object, Object> packageParams, String API_KEY) {  
        StringBuffer sb = new StringBuffer();  
        Set es = packageParams.entrySet();  
        Iterator it = es.iterator();  
        while (it.hasNext()) {  
            Map.Entry entry = (Map.Entry) it.next();  
            String k = (String) entry.getKey();  
            String v = (String) entry.getValue();  
            if (null != v && !"".equals(v) && !"sign".equals(k) && !"key".equals(k)) {  
                sb.append(k + "=" + v + "&");  
            }  
        }  
        sb.append("key=" + API_KEY);  
        String sign = MD5Util.MD5Encode(sb.toString(), characterEncoding).toUpperCase();  
        return sign;  
    }  
  
    /** 
     * @author 
     * @date 2016-4-22 
     * @Description:将请求参数转换为xml格式的string 
     * @param parameters 
     *            请求参数 
     * @return 
     */  
    public static String getRequestXml(SortedMap<Object, Object> parameters) {  
        StringBuffer sb = new StringBuffer();  
        sb.append("<xml>");  
        Set es = parameters.entrySet();  
        Iterator it = es.iterator();  
        while (it.hasNext()) {  
            Map.Entry entry = (Map.Entry) it.next();  
            String k = (String) entry.getKey();  
            String v = (String) entry.getValue();  
            if ("attach".equalsIgnoreCase(k) || "body".equalsIgnoreCase(k) || "sign".equalsIgnoreCase(k)) {  
                sb.append("<" + k + ">" + "<![CDATA[" + v + "]]></" + k + ">");  
            } else {  
                sb.append("<" + k + ">" + v + "</" + k + ">");  
            }  
        }  
        sb.append("</xml>");  
        return sb.toString();  
    }  
  
    /** 
     * 取出一个指定长度大小的随机正整数. 
     *  
     * @param length 
     *            int 设定所取出随机数的长度。length小于11 
     * @return int 返回生成的随机数。 
     */  
    public static int buildRandom(int length) {  
        int num = 1;  
        double random = Math.random();  
        if (random < 0.1) {  
            random = random + 0.1;  
        }  
        for (int i = 0; i < length; i++) {  
            num = num * 10;  
        }  
        return (int) ((random * num));  
    }  
  
    /** 
     * 获取当前时间 yyyyMMddHHmmss 
     *  
     * @return String 
     */  
    public static String getCurrTime() {  
        Date now = new Date();  
        SimpleDateFormat outFormat = new SimpleDateFormat("yyyyMMddHHmmss");  
        String s = outFormat.format(now);  
        return s;  
    }  
	
}

HttpUtil类如下:

package com.demodashi.pay.util;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.net.URL;
import java.net.URLConnection;

/**
 * http工具类,负责发起post请求并获取的返回
 */
public class HttpUtil {

    private final static int CONNECT_TIMEOUT = 5000; // in milliseconds  
    private final static String DEFAULT_ENCODING = "UTF-8";  
      
    public static String postData(String urlStr, String data){  
        return postData(urlStr, data, null);
    }
      
    public static String postData(String urlStr, String data, String contentType){  
        BufferedReader reader = null;  
        try {  
            URL url = new URL(urlStr);  
            URLConnection conn = url.openConnection();  
            conn.setDoOutput(true);  
            conn.setConnectTimeout(CONNECT_TIMEOUT);  
            conn.setReadTimeout(CONNECT_TIMEOUT);  
            if(contentType != null)  
                conn.setRequestProperty("content-type", contentType);  
            OutputStreamWriter writer = new OutputStreamWriter(conn.getOutputStream(), DEFAULT_ENCODING);  
            if(data == null)  
                data = "";  
            writer.write(data);   
            writer.flush();  
            writer.close();    
  
            reader = new BufferedReader(new InputStreamReader(conn.getInputStream(), DEFAULT_ENCODING));  
            StringBuilder sb = new StringBuilder();  
            String line = null;  
            while ((line = reader.readLine()) != null) {  
                sb.append(line);  
                sb.append("\r\n");  
            }  
            return sb.toString();  
        } catch (IOException e) {  
            //logger.error("Error connecting to " + urlStr + ": " + e.getMessage());  
        } finally {  
            try {  
                if (reader != null)  
                    reader.close();  
            } catch (IOException e) {  
            }  
        }  
        return null;  
    }  

}

XMLUtil4jdom类如下:

package com.demodashi.pay.util;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

import org.jdom.Document;
import org.jdom.Element;
import org.jdom.JDOMException;
import org.jdom.input.SAXBuilder;


public class XMLUtil4jdom {

    /** 
     * 解析xml,返回第一级元素键值对。如果第一级元素有子节点,则此节点的值是子节点的xml数据。 
     * @param strxml 
     * @return 
     * @throws JDOMException 
     * @throws IOException 
     */  
    public static Map doXMLParse(String strxml) throws JDOMException, IOException {  
        strxml = strxml.replaceFirst("encoding=\".*\"", "encoding=\"UTF-8\"");  
  
        if(null == strxml || "".equals(strxml)) {
            return null;  
        }
          
        Map<String, String> m = new HashMap<String, String>(); 
        InputStream in = new ByteArrayInputStream(strxml.getBytes("UTF-8"));  
        SAXBuilder builder = new SAXBuilder();  
        Document doc = builder.build(in);  
        Element root = doc.getRootElement();  
        List list = root.getChildren();  
        Iterator it = list.iterator();  
        while(it.hasNext()) {  
            Element e = (Element) it.next();  
            String k = e.getName();  
            String v = "";  
            List children = e.getChildren();  
            if(children.isEmpty()) {  
                v = e.getTextNormalize();  
            } else {  
                v = XMLUtil4jdom.getChildrenText(children);  
            }  
              
            m.put(k, v);  
        }  
          
        //关闭流  
        in.close();  
          
        return m;  
    }  
      
    /** 
     * 获取子结点的xml 
     * @param children 
     * @return String 
     */  
    public static String getChildrenText(List children) {  
        StringBuffer sb = new StringBuffer();  
        if(!children.isEmpty()) {  
            Iterator it = children.iterator();  
            while(it.hasNext()) {  
                Element e = (Element) it.next();  
                String name = e.getName();  
                String value = e.getTextNormalize();  
                List list = e.getChildren();  
                sb.append("<" + name + ">");  
                if(!list.isEmpty()) {  
                    sb.append(XMLUtil4jdom.getChildrenText(list));  
                }  
                sb.append(value);  
                sb.append("</" + name + ">");  
            }  
        }  
          
        return sb.toString();  
    }  

}

2、支付回调

支付完成后,微信会把相关支付结果和用户信息发送到我们上面指定的那个回调地址,我们需要接收处理,并返回应答。对后台通知交互时,如果微信收到商户的应答不是成功或超时,微信认为通知失败,微信会通过一定的策略定期重新发起通知,尽可能提高通知的成功率,但微信不保证通知最终能成功。 (通知频率为15/15/30/180/1800/1800/1800/1800/3600,单位:秒)

关于支付回调接口,我们首先要对于支付结果通知的内容进行签名验证,然后根据支付结果进行相应的处理流程即可。

支付回调需要在微信公众号的微信支付里面设置回调地址:

image.png

/**
 * 微信平台发起的回调方法,
 * 调用我们这个系统的这个方法接口,将扫描支付的处理结果告知我们系统
 * @throws JDOMException
 * @throws Exception
 */
public void weixinNotify(HttpServletRequest request, HttpServletResponse response) throws JDOMException, Exception{
       //读取参数  
       InputStream inputStream ;  
       StringBuffer sb = new StringBuffer();  
       inputStream = request.getInputStream();  
       String s ;  
       BufferedReader in = new BufferedReader(new InputStreamReader(inputStream, "UTF-8"));  
       while ((s = in.readLine()) != null){  
           sb.append(s);
       }
       in.close();
       inputStream.close();
 
       //解析xml成map  
       Map<String, String> m = new HashMap<String, String>();  
       m = XMLUtil4jdom.doXMLParse(sb.toString());  
       
       //过滤空 设置 TreeMap  
       SortedMap<Object,Object> packageParams = new TreeMap<Object,Object>();        
       Iterator it = m.keySet().iterator();  
       while (it.hasNext()) {  
           String parameter = (String) it.next();
           String parameterValue = m.get(parameter);
           
           String v = "";  
           if(null != parameterValue) {
               v = parameterValue.trim();  
           }  
           packageParams.put(parameter, v);  
       }  
         
       // 账号信息  
       String key = PayConfigUtil.API_KEY; //key  
 
       //判断签名是否正确  
       if(PayToolUtil.isTenpaySign("UTF-8", packageParams,key)) {  
           //------------------------------  
           //处理业务开始  
           //------------------------------  
           String resXml = "";  
           if("SUCCESS".equals((String)packageParams.get("result_code"))){  
               // 这里是支付成功  
               //////////执行自己的业务逻辑////////////////  
               String mch_id = (String)packageParams.get("mch_id");  
               String openid = (String)packageParams.get("openid");  
               String is_subscribe = (String)packageParams.get("is_subscribe");  
               String out_trade_no = (String)packageParams.get("out_trade_no");  
               
               String total_fee = (String)packageParams.get("total_fee");  
               
               //////////执行自己的业务逻辑//////////////// 
               //暂时使用最简单的业务逻辑来处理:只是将业务处理结果保存到session中
               //(根据自己的实际业务逻辑来调整,很多时候,我们会操作业务表,将返回成功的状态保留下来)
               request.getSession().setAttribute("_PAY_RESULT", "OK");
               
               System.out.println("支付成功");  
               //通知微信.异步确认成功.必写.不然会一直通知后台.八次之后就认为交易失败了.  
               resXml = "<xml>" + "<return_code><![CDATA[SUCCESS]]></return_code>"  
                       + "<return_msg><![CDATA[OK]]></return_msg>" + "</xml> ";  
                 
           } else {
               resXml = "<xml>" + "<return_code><![CDATA[FAIL]]></return_code>"  
                       + "<return_msg><![CDATA[报文为空]]></return_msg>" + "</xml> ";  
           }
           //------------------------------  
           //处理业务完毕  
           //------------------------------  
           BufferedOutputStream out = new BufferedOutputStream(  
                   response.getOutputStream());  
           out.write(resXml.getBytes());  
           out.flush();  
           out.close();  
       } else{  
       	System.out.println("通知签名验证失败");  
       }
         
}

3、支付后网页自动跳转

web页面弹出二维码后,就开启轮询,询问系统后台支付有微信平台的成功支付返回了,如果有,则跳转到支付成功的页面。

<%@ page language="java" pageEncoding="UTF-8"%>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<script type="text/javascript" charset="utf-8" src="/resource/js/jquery-2.min.js"></script>
<script type="text/javascript" charset="utf-8" src="/resource/js/layer/layer.js"></script>
<title>微信扫码支付例子</title>
</head>
<body>
<form id="pay_form" method="post" >
<h1>可乐特价:0.1元/罐 <input id="pay_submit" name="but" type="button" value="微信支付"/></h1>
</form>
</body>
<script>
$(function(){
	$("#pay_submit").click(function(){
	    buy('001');//传入可乐的ID号
	});
	
});

/**
 * 购买
 */
function buy(productId){
	//打开付费二维码 -- 微信二维码
	layer.open({
		area: ['300px', '300px'],
        type: 2,
        closeBtn: false,
        title: false,
        shift: 2,
        shadeClose: true,
        content:'../user/qrcode.do?productId=' + productId
    });
	
	//重复执行某个方法 
	var t1 = window.setInterval("getPayState('" + productId + "')",1500); 
}

function getPayState(productId){
	var url = '../user/hadPay.do?productId=' + productId;
	//轮询是否已经付费
	$.ajax({
    	type:'post',
   		url:url,
   		data:{productId:productId},
   		cache:false,
   		async:true,
   		success:function(json){
   			if(json.result == 0){
   				location.href = '/result.jsp';
   			}
   		},
   	    error:function(){
   	    	layer.msg("执行错误!", 8);
   	    }
   	});
}
</script> 
</html>

三、运行效果

项目导入eclipse后,发表到tomcat中运行,或者通过jetty运行,跑起来后,访问:

image.png

点击微信支付:

image.png

这个时候在手机上用微信扫码:

image.png

支付成功后:

image.png

然后web网页会跳转到购买成功的页面,这里需要注意,微信支付回调接口,最好部署在公网的服务器上,这样能被回调,我本地使用改hosts的方法来让支付回调,不成功

image.png

四、注意点

本例子为了演示,所以一些业务逻辑特别简单,例如:订单号的生产,这里只是简单的用当前时间long数字来表示:

String out_trade_no = "" + System.currentTimeMillis(); //订单号 (调整为自己的生产逻辑)

实际开发的时候需要考虑并且情况下的订单号的唯一性。

还有,回调接口,考虑很简单:

//////////执行自己的业务逻辑//////////////// 
//暂时使用最简单的业务逻辑来处理:只是将业务处理结果保存到session中
//(根据自己的实际业务逻辑来调整,很多时候,我们会操作业务表,将返回成功的状态保留下来)
request.getSession().setAttribute("_PAY_RESULT", "OK");
                
System.out.println("支付成功");

实际开发,要把支付成功DB保存下来,以及回调信息log下来等等

五、关于微信支付xml外部实体注入漏洞

参考文章:

http://www.cnblogs.com/hero123/p/9282753.html

本例子对应改动:改写读取XML的逻辑。

加入防注入属性设定:

// 这是优先选择. 如果不允许DTDs (doctypes) ,几乎可以阻止所有的XML实体攻击
String FEATURE = "http://apache.org/xml/features/disallow-doctype-decl";
builder.setFeature(FEATURE, true);

FEATURE = "http://xml.org/sax/features/external-general-entities";
builder.setFeature(FEATURE, false);

FEATURE = "http://xml.org/sax/features/external-parameter-entities";
builder.setFeature(FEATURE, false);

FEATURE = "http://apache.org/xml/features/nonvalidating/load-external-dtd";
builder.setFeature(FEATURE, false);
System.out.println("2018-07-21 加入了修复XXE攻击漏洞");

整个类的代码如下:

package com.demodashi.pay.util;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

import org.jdom.Document;
import org.jdom.Element;
import org.jdom.JDOMException;
import org.jdom.input.SAXBuilder;


public class XMLUtil4jdom {

    /** 
     * 解析xml,返回第一级元素键值对。如果第一级元素有子节点,则此节点的值是子节点的xml数据。 
     * @param strxml 
     * @return 
     * @throws JDOMException 
     * @throws IOException 
     */
    public static Map doXMLParse(String strxml) throws JDOMException, IOException {  
        strxml = strxml.replaceFirst("encoding=\".*\"", "encoding=\"UTF-8\"");  
  
        if(null == strxml || "".equals(strxml)) {
            return null;  
        }
        
        Map<String, String> m = new HashMap<String, String>(); 
        InputStream in = new ByteArrayInputStream(strxml.getBytes("UTF-8"));  
        SAXBuilder builder = new SAXBuilder();
        
        //-----------------------2018-07-21---------------------------------------------
        // 参考  http://www.cnblogs.com/hero123/p/9282753.html
        // 这是优先选择. 如果不允许DTDs (doctypes) ,几乎可以阻止所有的XML实体攻击
        String FEATURE = "http://apache.org/xml/features/disallow-doctype-decl";
        builder.setFeature(FEATURE, true);

        FEATURE = "http://xml.org/sax/features/external-general-entities";
        builder.setFeature(FEATURE, false);

        FEATURE = "http://xml.org/sax/features/external-parameter-entities";
        builder.setFeature(FEATURE, false);

        FEATURE = "http://apache.org/xml/features/nonvalidating/load-external-dtd";
        builder.setFeature(FEATURE, false);
        System.out.println("2018-07-21 加入了修复XXE攻击漏洞");
        //------------------------------------------------------------------------------
        
        Document doc = builder.build(in);  
        Element root = doc.getRootElement();  
        List list = root.getChildren();  
        Iterator it = list.iterator();  
        while(it.hasNext()) {
            Element e = (Element) it.next();  
            String k = e.getName();  
            String v = "";  
            List children = e.getChildren();  
            if(children.isEmpty()) {  
                v = e.getTextNormalize();  
            } else {  
                v = XMLUtil4jdom.getChildrenText(children);  
            }  
              
            m.put(k, v);  
        }  
          
        //关闭流  
        in.close();  
          
        return m;  
    }  
      
    /** 
     * 获取子结点的xml 
     * @param children 
     * @return String 
     */  
    public static String getChildrenText(List children) {  
        StringBuffer sb = new StringBuffer();  
        if(!children.isEmpty()) {  
            Iterator it = children.iterator();  
            while(it.hasNext()) {  
                Element e = (Element) it.next();  
                String name = e.getName();  
                String value = e.getTextNormalize();  
                List list = e.getChildren();  
                sb.append("<" + name + ">");  
                if(!list.isEmpty()) {  
                    sb.append(XMLUtil4jdom.getChildrenText(list));  
                }  
                sb.append(value);  
                sb.append("</" + name + ">");  
            }  
        }  
          
        return sb.toString();  
    }  

}

该实现方式来源网络仅供参考。


本实例支付的费用只是购买源码的费用,如有疑问欢迎在文末留言交流,如需作者在线代码指导、定制等,在作者开启付费服务后,可以点击“购买服务”进行实时联系,请知悉,谢谢
手机上随时阅读、收藏该文章 ?请扫下方二维码