支付渠道接入设计及实现

聚合支付:也称“融合支付”,是指只从事“支付、结算、清算”服务之外的“支付服务”,依托银行、非银机构或清算组织,借助银行、非银机构或清算组织的支付通道与清结算能力,利用自身的技术与服务集成能力,将一个以上的银行、非银机构或清算组织的支付服务,整合到一起,为商户提供包括但不限于“支付通道服务”、“集合对账服务”、“技术对接服务”、“差错处理服务”、“金融服务引导”、“会员账户服务”、“作业流程软件服务”、“运行维护服务”、“终端提供与维护”等服务内容,以此减少商户接入、维护支付结算服务时面临的成本支出,提高商户支付结算系统运行效率的,并收取增值收益的支付服务
-----百度百科

本文主要介绍了聚合支付系统中支付渠道接入模块的设计和实现,目录如下:

目录

1,知识准备
  1. 加密和解密
  2. 摘要加密
  3. Base64
  4. 对称加密
2,支付渠道配置设计
  1. 支付接口类型
  2. 支付接口
  3. 支付通道
  4. 支付通道账户
3,支付渠道服务开发设计
  1. 支付流程说明
  2. 支付渠道接入设计
4,实战(支付宝接口接入)
5,总结

一,知识准备

在讨论支付渠道接入设计之前,我们先来了解下支付过程中用到的安全相关知识。

1, 加密和解密

加密技术源远流长,自从古代有了信息的传递和存储,就有了加密技术的运用。此后,很长一段时间里,加密及解密技术在军事、政治、外交、金融等特殊领域里被普遍采用,并经过长时间的研究和发展,形成了比较完备的一门学科——密码学。

密码学是研究加密方法、秘密通信的原理,以及解密方法、破译密码的方法的一门科学。

加密和解密的过程大致如下:

  • 首先,信息的发送方准备好要发送信息的原始形式,叫作明文。

  • 然后对明文经过一系列变换后形成信息的另一种不能直接体现明文含义的形式,叫作密文。

  • 由明文转换为密文的过程叫作加密。

  • 在加密时所采用的一组规则或方法称为加密算法。

  • **解密:**接收者在收到密文后,再把密文还原成明文,以获得信息的具体内容,这个过程叫作解密。

  • **解密算法:**解密时也要运用一系列与加密算法相对应的方法或规则,这种方法或规则叫作解密算法。

  • **密钥:**在加密、解密过程中,由通信双方掌握的参数信息控制具体的加密和解密过程,这个参数叫作密钥。

密钥分为加密密钥和解密密钥,分别用于加密过程和解密过程。

**称密钥密码体制:**在加密和解密的过程中,如果采用的加密密钥与解密密钥相同,或者从一个很容易计算出另一个,则这种方法叫作对称密钥密码体制,也叫作单钥密码体制。

**双钥密码体制:**反之,如果加密和解密的密钥并不相同,或者从一个很难计算出另外一个,就叫作不对称密钥密码系统或者公开密钥密码体制,也叫作双钥密码体制。

2, 摘要加密

摘要数据:47bce5c74f589f4867dbd57e9ca9f808

摘要是哈希值,我们通过散列算法比如MD5算法就可以得到这个哈希值。摘要只是用于验证数据完 整性和唯一性的哈希值,

不管原始数据是什么样的,得到的哈希值都是固定长度的。

不管原始数据是什么样的,得到的哈希值都是固定长度的,也就是说摘要并不是原始数据加密后的 密文,只是一个验证身份的令牌。所以我们无法通过摘要解密得到原始数据。

常用的摘要算法有:MD5算法(MD2 、MD4、MD5),SHA算法(SHA1、SHA256、SHA384、 SHA512),HMAC算法 摘要加密算法特性:

1:任何数据加密,得到的密文长度固定。

2:密文是无法解密的(不可逆)。

  1. MD5

    MD5信息摘要算法(英语:MD5 Message-Digest Algorithm),一种被广泛使用的密码散列函 数,可以产生出一个128位(16字节)的散列值(hash value),用于确保信息传输完整一致。MD5由 美国密码学家罗纳德·李维斯特(Ronald Linn Rivest)设计,于1992年公开,用以取代MD4算法。这套 算法的程序在 RFC 1321 标准中被加以规范。1996年后该算法被证实存在弱点,可以被加以破解,对于 需要高度安全性的数据,专家一般建议改用其他算法,如SHA-2。2004年,证实MD5算法无法防止碰撞 (collision),因此不适用于安全性认证,如SSL公开密钥认证或是数字签名等用途。

    MD5存在一个缺陷,只要明文相同,那么生成的MD5码就相同,于是攻击者就可以通过撞库的方式 来破解出明文。加盐就是向明文中加入指定字符,主要用于混淆用户、并且增加MD5撞库破解难度,这 样一来即使撞库破解,知道了明文,但明文也是混淆了的,真正需要用到的数据也需要从明文中摘取, 摘取范围、长度、摘取方式都是个谜,如此一来就大大增加了暴力破解的难度,使其几乎不可能破解。

    我们来编写一个MD5案例 ,代码如下:

    public class MD5 {
        /**
        * MD5方法
        * @param text 明文
        * @return 密文
        * @throws Exception
        */
        public static String md5(String text) throws Exception {
            //加密后的字符串
            String encode= DigestUtils.md5Hex(text);
            return encode;
        }
        /**
        * MD5方法
        * @param text 明文
        * @param key 盐
        * @return 密文
        * @throws Exception
        */
        public static String md5(String text, String key) throws Exception {
            //加密后的字符串
            String encode= DigestUtils.md5Hex(text + key);
            return encode;
        }
        /**
        * MD5验证方法
        * @param text 明文
        * @param key 密钥
        * @param md5 密文
        * @return true/false
        * @throws Exception
        */
        public static boolean verify(String text, String key, String md5) throws
        Exception {
            //根据传入的密钥进行验证
            String md5Text = md5(text, key);
            return md5Text.equalsIgnoreCase(md5);
        }
    }
    
    
  2. 验签

    验签其实就是签名验证,MD5加密算法经常用于签名安全验证。关于验签,我们用下面这个流程图来说明:

验签.png

1:order-service向pay-service服务发送数据前,先对数据进行处理。

2:先把数据封装到Map中,再对数据进行排序。

3:获取排序后的数据的MD5只,并将MD5只封装到Map中。

4:把带有MD5只的Map传给pay-service。

5:pay-service中获取到数据,移除Map中的MD5值,再将Map排序。

6:获取排序后的MD5值,并且对比传过来的MD5值。

7:两个MD5值如果一样,证明该数据安全,没有被修改,如果不一样,证明数据被修改了。

3, Base64

Base64是网络上最常见的用于传输8Bit字节码的编码方式之一,Base64就是一种基于64个可打印 字符来表示二进制数据的方法。

Base64编码是从二进制到字符的过程,可用于在HTTP环境下传递较长的标识信息。采用Base64编 码具有不可读性,需要解码后才能阅读。

Base64由于以上优点被广泛应用于计算机的各个领域,然而由于输出内容中包括两个以上“符号类” 字符(+, /, =),不同的应用场景又分别研制了Base64的各种“变种”。为统一和规范化Base64的输出, Base62x被视为无符号化的改进版本,但Base62x的性能效率偏低,目前还不建议在项目中使用。

标准的Base64并不适合直接放在URL里传输,因为URL编码器会把标准Base64中的“/”和“+”字符变 为形如“%XX”的形式,而这些“%”号在存入数据库时还需要再进行转换,因为ANSI SQL中已将“%”号用作 通配符。

为解决此问题,可采用一种用于URL的改进Base64编码,它在末尾填充’='号,并将标准Base64中 的“+”和“/”分别改成了“-”和“_”,这样就免去了在URL编解码和数据库存储时所要作的转换,避免了编码信 息长度在此过程中的增加,并统一了数据库、表单等处对象标识符的格式。

Base64Util 代码如下:

public class Base64Util {
    /***
    * 普通解密操作
    * @param encodedText
    * @return
    */
    public static byte[] decode(String encodedText){
        final Base64.Decoder decoder = Base64.getDecoder();
        return decoder.decode(encodedText);
    }
    /***
    * 普通加密操作
    * @param data
    * @return
    */
    public static String encode(byte[] data){
        final Base64.Encoder encoder = Base64.getEncoder();
        return encoder.encodeToString(data);
    }
    /***
    * 解密操作
    * @param encodedText
    * @return
    */
    public static byte[] decodeURL(String encodedText){
        final Base64.Decoder decoder = Base64.getUrlDecoder();
        return decoder.decode(encodedText);
    }
    /***
    * 加密操作
    * @param data
    * @return
    */
    public static String encodeURL(byte[] data){
        final Base64.Encoder encoder = Base64.getUrlEncoder();
        return encoder.encodeToString(data);
    }
}

4,对称加密

前面我们学习了MD5,MD5加密后本质上是无法解密,是一个不可逆的过程,而网上有很多解密其 实都是一种穷举法对比,根本不存在破解方法。

在业务中,很多时候存在解密的需要,我们可以采用对称加密,对称加密是指加密和解密都采用相 同的秘钥。使用对称加密,发送方使用密钥将明文数据加密成密文,然后发送出去,接收方收到密文 后,使用同一个密钥将密文解密成明文读取,我们可以用一个很形象的例子来解释对称加密,例如:只 有一模一样的钥匙才能打开同一个锁,也只有那把钥匙能锁住那把锁。

  1. AES详解

    典型的对称加密算法有DES、3DES、AES,但AES加密算法的安全性要高于DES和3DES,所以AES 已经成为了主要的对称加密算法。

    AES加密算法就是众多对称加密算法中的一种,它的英文全称是Advanced Encryption Standard, 翻译过来是高级加密标准,它是用来替代之前的DES加密算法的。

    要理解AES的加密流程,会涉及到AES加密的五个关键词,分别是:分组密码体制、Padding、密 钥、初始向量IV和四种加密模式,下面我们一一介绍。

    • **分组密码体制:**所谓分组密码体制就是指将明文切成一段一段的来加密,然后再把一段一段的密文 拼起来形成最终密文的加密方式。AES采用分组密码体制,即AES加密会首先把明文切成一段一段的,而 且每段数据的长度要求必须是128位16个字节,如果最后一段不够16个字节了,就需要用Padding来把 这段数据填满16个字节,然后分别对每段数据进行加密,最后再把每段加密数据拼起来形成最终的密 文。

    分组密码填充.png

    • **Padding:**Padding就是用来把不满16个字节的分组数据填满16个字节用的,它有三种模式 PKCS5、PKCS7和NOPADDING。PKCS5是指分组数据缺少几个字节,就在数据的末尾填充几个字节的 几,比如缺少5个字节,就在末尾填充5个字节的5。PKCS7是指分组数据缺少几个字节,就在数据的末 尾填充几个字节的0,比如缺少7个字节,就在末尾填充7个字节的0。NoPadding是指不需要填充,也就 是说数据的发送方肯定会保证最后一段数据也正好是16个字节。那如果在PKCS5模式下,最后一段数据 的内容刚好就是16个16怎么办?那解密端就不知道这一段数据到底是有效数据还是填充数据了,因此对 于这种情况,PKCS5模式会自动帮我们在最后一段数据后再添加16个字节的数据,而且填充数据也是16 个16,这样解密段就能知道谁是有效数据谁是填充数据了。PKCS7最后一段数据的内容是16个0,也是 同样的道理。解密端需要使用和加密端同样的Padding模式,才能准确的识别有效数据和填充数据。我 们开发通常采用PKCS7 Padding模式。

      PKCS5填充方式:
      PKCS5.png

    • **初始向量IV:**初始向量IV的作用是使加密更加安全可靠,我们使用AES加密时需要主动提供初始向 量,而且只需要提供一个初始向量就够了,后面每段数据的加密向量都是前面一段的密文。初始向量IV 的长度规定为128位16个字节,初始向量的来源为随机生成。至于为什么初始向量能使加密更安全可靠。

    • 密钥:AES要求密钥的长度可以是128位16个字节、192位或者256位,位数越高,加密强度自然越 大,但是加密的效率自然会低一些,因此要做好衡量。我们开发通常采用128位16个字节的密钥,我们 使用AES加密时需要主动提供密钥,而且只需要提供一个密钥就够了,每段数据加密使用的都是这一个密钥,密钥来源为随机生成。

    • 四种加密模式:AES一共有四种加密模式,分别是ECB(电子密码本模式)CBC(密码分组链接模式)CFB、OFB,我们一般使用的是ECB和CBC模式。四种模式中除了ECB相对不安全之外,其它三 种模式的区别并没有那么大,因此这里只会对ECB和CBC模式做一下对比,看看它们在做什么。

ECB加密模式.png

ECB模式是最基本的加密模式,即仅仅使用明文和密钥来加密数据,相同的明文块会被加密成相同的密文块, 这样明文和密文的结构将是完全一样的,就会更容易被破解,相对来说不是那么安全,因此很少使用。

CBC加密模式.png

CBC模式则比ECB模式多了一个初始向量IV,加密的时候,第一个明文块会首先和初始向量IV做异或操作,然后再经过密钥加密,然后第一个密文块又会作为第二个明文块的加密向量来异或,依次类推下去,这样相同的明文块加密出的密文块就是不同的,明文的结构和密文的结构也将是不同的,因此更加安全。

  1. AES算法下载

java 中的 AES 秘钥为 256bit 算法执行时,会遇到 Illegal key size or default parameters 错,原因是因为本地没有对应的算法库,需要下载对应JDK版本的算法库。

JDK8 jar 包下载地址: https://www.oracle.com/java/technologies/javase-jce8-downloads.html

JDK7 jar 包下载地址: https://www.oracle.com/java/technologies/javase-jce7-downloads.html

JDK6 jar 包下载地址: https://www.oracle.com/java/technologies/jce-6-download.html

下载后解压,可以看到 local_policy.jar 和 US_export_policy.jar 以及 readme.txt 。

如果安装了JRE,将两个jar文件放到 %JRE_HOME%\lib\security 目录下覆盖原来的文件。

如果安装了JDK,还要将两个jar文件也放到 %JDK_HOME%\jre\lib\security 目录下覆盖原来文件。

  1. AES实战

    使用AES加密、解密,他们的执行过程都是一样的,步骤如下:

    1:加载加密解密算法处理对象(包含算法、秘钥管理)

    2:根据不同算法创建秘钥

    3:设置加密模式(无论是加密还是解析,模式一致)

    4:初始化加密配置

    5:执行加密/解密

    我们编写一个类 AESCoder ,既可以实现加密,也可以实现解密,代码如下:

    public abstract class AESCoder extends SecurityCoder {
    	public static final String KEY_ALGORITHM = "AES";
    
    	/**
    	 * @param rawKey
    	 *            密钥
    	 * @param clearPwd
    	 *            明文字符串
    	 * @return 密文字节数组
    	 */
    	public static byte[] encrypt(byte[] rawKey, String clearPwd) {
    		try {
    			SecretKeySpec secretKeySpec = new SecretKeySpec(rawKey, KEY_ALGORITHM);
    			Cipher cipher = Cipher.getInstance(KEY_ALGORITHM);
    			cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec);
    			byte[] encypted = cipher.doFinal(clearPwd.getBytes());
    			return encypted;
    		} catch (Exception e) {
    			return null;
    		}
    	}
    
    	/**
    	 * @param encrypted
    	 *            密文字节数组
    	 * @param rawKey
    	 *            密钥
    	 * @return 解密后的字符串
    	 */
    	public static String decrypt(byte[] encrypted, byte[] rawKey) {
    		try {
    			SecretKeySpec secretKeySpec = new SecretKeySpec(rawKey, KEY_ALGORITHM);
    			Cipher cipher = Cipher.getInstance(KEY_ALGORITHM);
    			cipher.init(Cipher.DECRYPT_MODE, secretKeySpec);
    			byte[] decrypted = cipher.doFinal(encrypted);
    			return new String(decrypted);
    		} catch (Exception e) {
    			e.printStackTrace();
    			return "";
    		}
    	}
    
    	/**
    	 * @param seed 种子数据
    	 * @return 密钥数据
    	 */
    	public static byte[] getRawKey(byte[] seed) {
    		byte[] rawKey = null;
    		try {
    			KeyGenerator kgen = KeyGenerator.getInstance(KEY_ALGORITHM);
    			SecureRandom secureRandom = SecureRandom.getInstance("SHA1PRNG");
    			secureRandom.setSeed(seed);
    			// AES加密数据块分组长度必须为128比特,密钥长度可以是128比特、192比特、256比特中的任意一个
    			kgen.init(128, secureRandom);
    			SecretKey secretKey = kgen.generateKey();
    			rawKey = secretKey.getEncoded();
    		} catch (NoSuchAlgorithmException e) {
    		}
    		return rawKey;
    	}
        /**
         * 将二进制转换成16进制 
         * <p>说明:</p>
         * <li></li>
         * @author DuanYong
         * @param buf
         * @return
         * @since 2017年11月16日上午8:59:33
         */
    	public static String parseByte2HexStr(byte buf[]) {
    		StringBuffer sb = new StringBuffer();
    		for (int i = 0; i < buf.length; i++) {
    			String hex = Integer.toHexString(buf[i] & 0xFF);
    			if (hex.length() == 1) {
    				hex = '0' + hex;
    			}
    			sb.append(hex.toUpperCase());
    		}
    		return sb.toString();
    	}
        /**
         * 将16进制转换为二进制 
         * <p>说明:</p>
         * <li></li>
         * @author DuanYong
         * @param hexStr
         * @return
         * @since 2017年11月16日上午8:59:51
         */
    	public static byte[] parseHexStr2Byte(String hexStr) {
    		if (hexStr.length() < 1){
                return null;
            }
    		byte[] result = new byte[hexStr.length() / 2];
    		for (int i = 0; i < hexStr.length() / 2; i++) {
    			int high = Integer.parseInt(hexStr.substring(i * 2, i * 2 + 1), 16);
    			int low = Integer.parseInt(hexStr.substring(i * 2 + 1, i * 2 + 2), 16);
    			result[i] = (byte) (high * 16 + low);
    		}
    		return result;
    	}
    }
    

二,支付渠道配置设计

渠道接入.png

支付渠道的接入主要由支付渠道配置和支付渠道服务开发组成:

支付渠道配置:主要是完成接入渠道所需相关参数的配置。

支付渠道服务开发:主要是根据系统支付渠道接入规范,开发对应支付渠道服务。

支付渠道配置设计如下图所示:
支付渠道配置设计.png

  1. 支付接口类型

    主要定义支付接口的类型,如:阿里支付,微信支付这类渠道类型。

    主要参数:

    • 接口类型代码:唯一标识一个渠道,如:阿里支付:alipay

    • 接口类型名称:名称,如:支付宝官方支付

    • 状态 :渠道开启/关闭状态控制

    • 备注信息 :描述信息

    • 配置定义描述:主要用于定义不同渠道参数配置项,以便在支付接口通道配置时自动生成配置项。

      自定义描述符说明如下:

      字段说明
      name字段名称,如:pid
      desc字段名称描述,如:商户PID
      type字段类型,取值:
      text->生成input文本输入框
      textarea->生成textarea文本输入域
      verify字段校验类型,取值:
      required->表示必填

      如支付宝渠道配置参数描述如下:

      [{
      	"name": "pid",
      	"desc": "商户PID",
      	"type": "text",
      	"verify": "required"
      }, {
      	"name": "appId",
      	"desc": "应用App ID",
      	"type": "text",
      	"verify": "required"
      }, {
      	"name": "alipayAccount",
      	"desc": "支付宝账户",
      	"type": "text",
      	"verify": "required"
      }, {
      	"name": "privateKey",
      	"desc": "应用私钥",
      	"type": "textarea",
      	"verify": "required"
      }, {
      	"name": "alipayPublicKey",
      	"desc": "支付宝公钥",
      	"type": "textarea"
      }, {
      	"name": "reqUrl",
      	"desc": "网关地址",
      	"type": "text",
      	"verify": "required"
      }] 
      

      界面设计效果:

支付接口类型配置.png

**数据库设计:**t_pay_interface_type

字段类型长度注释
IfTypeCodevarchar30接口类型代码
IfTypeNamevarchar30接口类型名称
Statustinyint1状态,0-关闭,1-开启
Paramvarchar4096接口配置定义描述,json字符串
Remarkvarchar128备注
CreateTimetimestamp0创建时间
UpdateTimetimestamp0更新时间
  1. 支付接口:

    支付接口与支付接口类型为一对多关系,主要配置具体的支付接口,如阿里支付接口类型下,包含H5支付,WAP支付,现金红包支付等各种支付接口。

    主要参数:

    • 接口类型:选择接口类型,如:支付宝官方支付
    • 接口代码:定义接口代码,唯一标识支付接口,如:alipay_pc
    • 接口名称:定义接口名称,描述该接口,如:支付宝PC支付
    • 支付类型:定义接口支付类型,如:网银支付
    • 应用场景:描述该接口使用的场景,如:移动APP,移动网页,PC网页,微信公众平台,手机扫码等
    • 扩展参数:
      当支付类型为网银支付时,可配置支持的银行列表.格式如:[{‘bank’:‘zhonghang’,‘code’:‘300008’},{‘bank’:‘nonghang’,‘code’:‘300009’}]
    • 状态 :接口开启/关闭状态控制
    • 备注信息:一些其他描述

    界面设计效果:

支付接口配置.png

**数据库设计:**t_pay_interface

字段类型长度注释
IfCodevarchar30接口代码
IfNamevarchar30接口名称
IfTypeCodevarchar30接口类型代码
PayTypevarchar2支付类型
Scenetinyint6应用场景,1:移动APP,2:移动网页,3:PC网页,4:微信公众平台,5:手机扫码
Statustinyint6接口状态,0-关闭,1-开启
Paramvarchar4096配置参数,json字符串
Remarkvarchar128备注
CreateTimetimestamp0创建时间
UpdateTimetimestamp0更新时间
Extravarchar1024扩展参数
  1. 支付接口通道:

    支付接口通道与具体的支付接口绑定,定义风控,费率,子账户相关参数,如:通道费率,单笔最大金额,日限额,开启/结束时间等。一个支付接口可以与多个通道绑定,以支持不同风控策略。

    通道基本信息设置:

    • 通道名称:定义通道名称,如:支付宝PC支付通道
    • 支付接口:下拉选择具体支付接口,如:支付宝PC支付
    • 支付类型:下拉选择具体支付类型,如:支付宝扫码支付
    • 通道状态 :通道开启/关闭状态控制
    • 备注信息:一些其他描述

    通道风控信息设置:

    • 当天交易金额(元):当天交易最大金额(日限额)
    • 单笔最大金额(元):单笔交易最大金额
    • 单笔最小金额(元):单笔交易最小金额
    • 交易开始时间:交易开始时间
    • 交易结束时间:交易结束时间
    • 风控状态:风控开启/关闭状态控制

    通道费率信息设置:

    • 通道费率(%):定义通道单笔交易费率

    界面设计效果:

    通道基本信息设置

通道基本设置.png

通道风控信息设置

通道风控设置.png

通道费率信息设置

通道费率设置.png

**数据库设计:**t_pay_passage

字段类型长度注释
idint11支付通道ID
PassageNamevarchar30通道名称
IfCodevarchar30接口代码
IfTypeCodevarchar30接口类型代码
PayTypevarchar2支付类型
Statustinyint6通道状态,0-关闭,1-开启
PassageRatedecimal20通道费率百分比
MaxDayAmountbigint20当天交易金额,单位分
MaxEveryAmountbigint20单笔最大金额,单位分
MinEveryAmountbigint20单笔最小金额,单位分
TradeStartTimevarchar20交易开始时间
TradeEndTimevarchar20交易结束时间
RiskStatustinyint6风控状态,0-关闭,1-开启
Remarkvarchar128备注
CreateTimetimestamp0创建时间
UpdateTimetimestamp0更新时间
  1. 支付接口通道账户:

    支付通道账户是支付通道下的一个子配置项,主要配置该通道下包含的账户信息以及账户风控信息,可以配置多个,多个账户根据配置使用策略(单一/轮询)来使用。分为基本信息和参数信息,基本信息描述账户相关基本信息,如名称,状态等。账户参数信息则是根据通道绑定的支付接口所属支付接口类型的配置定义描述来动态生成配置项。

    账户基本信息配置:

    • 账户名称:账户名称
    • 账户状态 :账户开启/关闭状态控制
    • 渠道商户ID:聚合支付商户ID
    • 轮询权重:轮询时的权重
    • 备注:一些说明

    账户参数信息配置:

    • 根据通道绑定的支付接口所属支付接口类型的配置定义描述来动态生成。

    账户风控信息配置:

    • 风控模式:指定风控模式:继承通道/自定义
    • 当天交易金额(元):当天交易最大金额(日限额)
    • 单笔最大金额(元):单笔交易最大金额
    • 单笔最小金额(元):单笔交易最小金额
    • 交易开始时间:交易开始时间
    • 交易结束时间:交易结束时间
    • 风控状态:风控开启/关闭状态控制

    **界面设计效果:**以支付宝官方支付接口类型为例。

    配置定义描述为:

    [{
    	"name": "pid",
    	"desc": "商户PID",
    	"type": "text",
    	"verify": "required"
    }, {
    	"name": "appId",
    	"desc": "应用App ID",
    	"type": "text",
    	"verify": "required"
    }, {
    	"name": "alipayAccount",
    	"desc": "支付宝账户",
    	"type": "text",
    	"verify": "required"
    }, {
    	"name": "privateKey",
    	"desc": "应用私钥",
    	"type": "textarea",
    	"verify": "required"
    }, {
    	"name": "alipayPublicKey",
    	"desc": "支付宝公钥",
    	"type": "textarea"
    }, {
    	"name": "reqUrl",
    	"desc": "网关地址",
    	"type": "text",
    	"verify": "required"
    }] 
    

    生成的界面为:

支护宝官方支付账户配置.png

账户风控配置界面:

账户风控配置.png

**前端自动生成配置项:**关键代码

admin.req({
            type: 'post',
            url: layui.setter.baseUrl + '/config/pay_passage/pay_config_get',
            data: {
                payPassageId: payPassageId
            },
            error: function(err){
                layer.alert(err);
            },
            success: function(res){
                if(res.code == 0){
                    $("#ifTypeNameSpan").html(res.data.ifTypeName);
                    var jsonObj = JSON.parse(res.data.param);
                    // 根据paramVal填充表单值
                    var htm = '';
                    $.each(jsonObj, function(i, obj){
                        htm += `
                                        <div class="layui-form-item">
                                            <label class="layui-form-label"> ` + obj.desc + ` [` + obj.name + `]` +`</label>
                                            <div class="layui-input-block"> `;
                        if(obj.type == 'text') {
                            htm += ` <input type="text" name="` + obj.name + `" lay-verify="` + obj.verify + `" placeholder="请输入` + obj.desc + `" autocomplete="off" class="layui-input">`;
                        }else if(obj.type == 'textarea') {
                            htm += ` <textarea required name="` + obj.name + `" lay-verify="` + obj.verify + `" placeholder="请输入` + obj.desc + `" class="layui-textarea"></textarea>`;
                        }
                        htm += ` </div>
                                        </div>
                                    </form>`;
                    });
                    htm += ``;
                    $('#paramInfo').html(htm);
                }else{
                    layer.alert(res.msg,{title:"请求失败"})
                }
            }
        })

        form.render();

**数据库设计:**t_pay_passage_account

字段类型长度注释
idint11账户ID
AccountNamevarchar30账户名称
PayPassageIdint11支付通道ID
IfCodevarchar30接口代码
IfTypeCodevarchar30接口类型代码
Paramvarchar4096账户配置参数,json字符串
Statustinyint2账户状态,0-停止,1-开启
PassageMchIdvarchar64通道商户ID
RiskModetinyint2风控模式,1-继承,2-自定义
PassageRatedecimal20通道费率百分比
MaxDayAmountbigint20当天交易金额,单位分
MaxEveryAmountbigint20单笔最大金额,单位分
MinEveryAmountbigint20单笔最小金额,单位分
TradeStartTimevarchar20交易开始时间
TradeEndTimevarchar20交易结束时间
RiskStatustinyint6风控状态,0-关闭,1-开启
CashCollStatustinyint2资金归集开关,0-关闭,1-开启
CashCollModetinyint2资金归集配置,1-继承全局配置,2-自定义
Remarkvarchar128备注
CreateTimetimestamp0创建时间
UpdateTimetimestamp0更新时间

三,支付渠道服务开发设计

  1. 支付流程说明:

聚合支付统一下单-异步通知-查询.png

**统一下单:**用户向商户系统发起支付请求,商户系统调用聚合支付统一下单接口,经过参数校验,创建订单,调用第三方支付接口完成下单操作,并且由第三方支付系统返回支付连接/支付表单参数/二维码等支付信息,到商户系统,商户系统根据返回数据,在客户端执行相应动作,如唤起客户端/打开支付页面等。用户根据支付界面完成支付。

**异步通知:**用户支付完成后,第三方支付系统会根据下单接口中的回调地址,回调聚合支付系统,推送支付结果,聚合支付系统根据支付结果更新订单状态,并回调商户系统,通知商户订单支付状态。

**订单查询:**有些第三方支付系统,不支持回调,聚合支付系统则根据提供的查询接口,开启定时任务查询。有结果反馈,则更新订单支付状态,并通知商户系统。商户系统也可通过聚合支付系统提供的查询接口,查询订单支付状态。

  1. 支付渠道服务开发设计

    **思路(简单但实用):**定义支付渠道服务接口(PaymentInterface)及相关方法,结合支付渠道服务接口实现类编码规则({支付接口类型代码}PaymentService),开发具体支付渠道服务,并交由Spring 容器管理。接口调用时,则通过预先约定的服务渠道支付接口类型代码,动态组装服务类名称,并根据名称在Spring容器中查找对应的实现类。
    PayOrderController.png

    以阿里支付渠道接口接入为例:

    1. 创建支付渠道接口服务实现类名称约定格式为:{支付接口类型代码}PaymentService,且必须继承BasePayment。如:AlipayPaymentService

    2. 重写getChannelName抽象方法,返回具体渠道接口类型代码,如

          @Override
          public String getChannelName() {
              return PayConstant.CHANNEL_NAME_ALIPAY;
          }
          
         String CHANNEL_NAME_ALIPAY = "alipay"; 				// 渠道名称:支付宝
      
    3. 定义配置类,如:AlipayConfig,这里字段取值来自接口所属通道账户配置

          private String pid;             // 合作伙伴身份partner
          private String appId;           // 应用App ID
          private String privateKey;      // 应用私钥
          private String alipayPublicKey; // 支付宝公钥
          private String alipayAccount;   // 支付宝账号
          private String reqUrl;          // 请求网关地址
          // RSA2
          public static String SIGNTYPE = "RSA2";
          // 编码
          public static String CHARSET = "UTF-8";
          // 返回格式
          public static String FORMAT = "json";
          //令牌地址
          public static String toAuth = "https://openauth.alipay.com/oauth2/publicAppAuthorize.htm";
          
          public AlipayConfig(){}
      
          public AlipayConfig(String payParam) {
              Assert.notNull(payParam, "init alipay config error");
              JSONObject object = JSON.parseObject(payParam);
              this.pid = object.getString("pid");
              this.appId = object.getString("appId");
              this.privateKey = object.getString("privateKey");
              this.alipayPublicKey = object.getString("alipayPublicKey");
              this.alipayAccount = object.getString("alipayAccount");
              this.reqUrl = object.getString("reqUrl");
          }
         //初始化配置
         AlipayConfig alipayConfig = new AlipayConfig(getPayParam(payOrder));
      
         /**
           * 获取三方支付配置信息
           */
          public String getPayParam(PayOrder payOrder) {
              String payParam = "";
              PayPassageAccount payPassageAccount = rpcCommonService.rpcPayPassageAccountService.findById(payOrder.getPassageAccountId());
              if(payPassageAccount != null && payPassageAccount.getStatus() == MchConstant.PUB_YES) {
                  payParam = payPassageAccount.getParam();
              }
              if(StringUtils.isBlank(payParam)) {
                  throw new ServiceException(RetEnum.RET_MGR_PAY_PASSAGE_ACCOUNT_NOT_EXIST);
              }
              return payParam;
          }
      
      
    4. 定位渠道接口服务:在统一下单接口方法中,根据订单包含的渠道ID,按照约定查找服务实现类

                  String channelId = payOrder.getChannelId();
                  String channelName = channelId.substring(0, channelId.indexOf("_"));
                  try {
                      paymentInterface = (PaymentInterface) SpringUtil.getBean(channelName.toLowerCase() +  "PaymentService");
                  }catch (BeansException e) {
                      _log.error(e, "支付渠道类型[channelId="+channelId+"]实例化异常");
                     ...
                  }
      

    四,实战(支付宝接口接入)

    1. 支付宝接口文档

      当面付:https://opendocs.alipay.com/apis/api_1/alipay.trade.precreate

    2. 创建支付渠道配置参数类:AlipayConfig

      @Component
      public class AlipayConfig extends BasePayConfig {
          private String pid;             // 合作伙伴身份partner
          private String appId;           // 应用App ID
          private String privateKey;      // 应用私钥
          private String alipayPublicKey; // 支付宝公钥
          private String alipayAccount;   // 支付宝账号
          private String reqUrl;          // 请求网关地址
          // RSA2
          public static String SIGNTYPE = "RSA2";
          // 编码
          public static String CHARSET = "UTF-8";
          // 返回格式
          public static String FORMAT = "json";
          //令牌地址
          public static String toAuth = "https://openauth.alipay.com/oauth2/publicAppAuthorize.htm";
          
          public AlipayConfig(){}
      
          public AlipayConfig(String payParam) {
              Assert.notNull(payParam, "init alipay config error");
              JSONObject object = JSON.parseObject(payParam);
              this.pid = object.getString("pid");
              this.appId = object.getString("appId");
              this.privateKey = object.getString("privateKey");
              this.alipayPublicKey = object.getString("alipayPublicKey");
              this.alipayAccount = object.getString("alipayAccount");
              //this.sellerId = object.getString("sellerId");
              //this.callback = object.getString("callback");
              this.reqUrl = object.getString("reqUrl");
              this.certPath = object.getString("certPath");
              this.alipayPublicCertPath = object.getString("alipayPublicCertPath");
              this.rootCertPath = object.getString("rootCertPath");
          }
      
        //geteer/setter
      }
      
    3. 创建支付接口服务类:AlipayPaymentService

      @Service
      public class AlipayPaymentService extends BasePayment {
      
          private static final MyLog _log = MyLog.getLog(AlipayPaymentService.class);
          public final static String PAY_CHANNEL_ALIPAY_QR_H5 = "alipay_qr_h5";	            // 支付宝当面付之H5支付
          public final static String PAY_CHANNEL_ALIPAY_QR_PC = "alipay_qr_pc";	            // 支付宝当面付之PC支付
      
          @Override
          public String getChannelName() {
              return PayConstant.CHANNEL_NAME_ALIPAY;
          }
      
          @Override
          public JSONObject pay(PayOrder payOrder) {
              String channelId = payOrder.getChannelId();
              JSONObject retObj;
              switch (channelId) {
                  case PAY_CHANNEL_ALIPAY_QR_H5 :
                      retObj = doAliPayQrH5Req(payOrder,"wap");
                      break;
                  case PAY_CHANNEL_ALIPAY_QR_PC :
                      retObj = doAliPayQrPcReq(payOrder,"pc");
                      break;
                  default:
                      retObj = buildRetObj(PayConstant.RETURN_VALUE_FAIL, "不支持的支付宝渠道[channelId="+channelId+"]");
                      break;
              }
              return retObj;
          }
          /**
           * 支付宝当面付(H5)支付
           * 收银员通过收银台或商户后台调用支付宝接口,可直接打开支付宝app付款。
           * @param payOrder
           * @return
           */
          public JSONObject doAliPayQrH5Req(PayOrder payOrder, String type) {
              String logPrefix = "【支付宝当面付之H5支付下单】";
              String payOrderId = payOrder.getPayOrderId();
              AlipayConfig alipayConfig = new AlipayConfig(getPayParam(payOrder));
              AlipayClient client = new DefaultAlipayClient(alipayConfig.getReqUrl(), alipayConfig.getAppId(), alipayConfig.getPrivateKey(), AlipayConfig.FORMAT, AlipayConfig.CHARSET, alipayConfig.getAlipayPublicKey(), AlipayConfig.SIGNTYPE);
              AlipayTradePrecreateRequest alipay_request = new AlipayTradePrecreateRequest();
              // 封装请求支付信息
              AlipayTradePrecreateModel model=new AlipayTradePrecreateModel();
              model.setOutTradeNo(payOrderId);
              model.setSubject(payOrder.getSubject());
              model.setTotalAmount(AmountUtil.convertCent2Dollar(payOrder.getAmount().toString()));
              model.setBody(payOrder.getBody());
              // 获取objParams参数
              String objParams = payOrder.getExtra();
              if (StringUtils.isNotEmpty(objParams)) {
                  try {
                      JSONObject objParamsJson = JSON.parseObject(objParams);
                      if(StringUtils.isNotBlank(objParamsJson.getString("discountable_amount"))) {
                          //可打折金额
                          model.setDiscountableAmount(objParamsJson.getString("discountable_amount"));
                      }
                      if(StringUtils.isNotBlank(objParamsJson.getString("undiscountable_amount"))) {
                          //不可打折金额
                          model.setUndiscountableAmount(objParamsJson.getString("undiscountable_amount"));
                      }
                  } catch (Exception e) {
                      _log.error("{}objParams参数格式错误!", logPrefix);
                  }
              }
              alipay_request.setBizModel(model);
              // 设置异步通知地址
              alipay_request.setNotifyUrl(alipayConfig.transformUrl(payConfig.getNotifyUrl(getChannelName())));
              // 设置同步跳转地址
              alipay_request.setReturnUrl(alipayConfig.transformUrl(payConfig.getReturnUrl(getChannelName())));
              String aliResult;
              String codeUrl = "";
              JSONObject retObj = buildRetObj();
              try {
                  aliResult = client.execute(alipay_request).getBody();
                  JSONObject aliObj = JSONObject.parseObject(aliResult);
                  JSONObject aliResObj = aliObj.getJSONObject("alipay_trade_precreate_response");
                  codeUrl = aliResObj.getString("qr_code");
              } catch (AlipayApiException e) {
                  _log.error(e, "");
                  retObj.put("errDes", "下单失败[" + e.getErrMsg() + "]");
                  retObj.put(PayConstant.RETURN_PARAM_RETCODE, PayConstant.RETURN_VALUE_FAIL);
                  return retObj;
              } catch (Exception e) {
                  _log.error(e, "");
                  retObj.put("errDes", "下单失败[调取通道异常]");
                  retObj.put(PayConstant.RETURN_PARAM_RETCODE, PayConstant.RETURN_VALUE_FAIL);
                  return retObj;
              }
              _log.info("{}生成支付宝二维码:codeUrl={}", logPrefix, codeUrl);
              rpcCommonService.rpcPayOrderService.updateStatus4Ing(payOrderId, null);
      
              String codeImgUrl = payConfig.getPayUrl() + "/qrcode_img_get?url=" + codeUrl + "&widht=200&height=200";
              StringBuffer payForm = new StringBuffer();
              String toPayUrl = payConfig.getPayUrl() + "/alipay/pay_"+type+".htm";
              payForm.append("<form style=\"display: none\" action=\""+toPayUrl+"\" method=\"post\">");
              payForm.append("<input name=\"mchOrderNo\" value=\""+payOrder.getMchOrderNo()+"\" >");
              payForm.append("<input name=\"payOrderId\" value=\""+payOrder.getPayOrderId()+"\" >");
              payForm.append("<input name=\"amount\" value=\""+payOrder.getAmount()+"\" >");
              payForm.append("<input name=\"codeUrl\" value=\""+codeUrl+"\" >");
              payForm.append("<input name=\"codeImgUrl\" value=\""+codeImgUrl+"\" >");
              payForm.append("<input type=\"submit\" value=\"立即支付\" style=\"display:none\" >");
              payForm.append("</form>");
              payForm.append("<script>document.forms[0].submit();</script>");
      
              retObj.put("payOrderId", payOrderId);
              JSONObject payInfo = new JSONObject();
              payInfo.put("payUrl",payForm);
              payInfo.put("payMethod",PayConstant.PAY_METHOD_FORM_JUMP);
              retObj.put("payParams", payInfo);
      
              _log.info("###### 商户统一下单处理完成 ######");
              return retObj;
          }
          /**
           * 支付宝当面付(PC)支付
           * 收银员通过收银台或商户后台调用支付宝接口,生成二维码后,展示给用户,由用户扫描二维码完成订单支付。
           * @param payOrder
           * @return
           */
          public JSONObject doAliPayQrPcReq(PayOrder payOrder, String type) {
              String logPrefix = "【支付宝当面付之PC支付下单】";
              String payOrderId = payOrder.getPayOrderId();
              AlipayConfig alipayConfig = new AlipayConfig(getPayParam(payOrder));
              AlipayClient client = new DefaultAlipayClient(alipayConfig.getReqUrl(), alipayConfig.getAppId(), alipayConfig.getPrivateKey(), AlipayConfig.FORMAT, AlipayConfig.CHARSET, alipayConfig.getAlipayPublicKey(), AlipayConfig.SIGNTYPE);
              AlipayTradePrecreateRequest alipay_request = new AlipayTradePrecreateRequest();
              // 封装请求支付信息
              AlipayTradePrecreateModel model=new AlipayTradePrecreateModel();
              model.setOutTradeNo(payOrderId);
              model.setSubject(payOrder.getSubject());
              model.setTotalAmount(AmountUtil.convertCent2Dollar(payOrder.getAmount().toString()));
              model.setBody(payOrder.getBody());
              // 获取objParams参数
              String objParams = payOrder.getExtra();
              if (StringUtils.isNotEmpty(objParams)) {
                  try {
                      JSONObject objParamsJson = JSON.parseObject(objParams);
                      if(StringUtils.isNotBlank(objParamsJson.getString("discountable_amount"))) {
                          //可打折金额
                          model.setDiscountableAmount(objParamsJson.getString("discountable_amount"));
                      }
                      if(StringUtils.isNotBlank(objParamsJson.getString("undiscountable_amount"))) {
                          //不可打折金额
                          model.setUndiscountableAmount(objParamsJson.getString("undiscountable_amount"));
                      }
                  } catch (Exception e) {
                      _log.error("{}objParams参数格式错误!", logPrefix);
                  }
              }
              alipay_request.setBizModel(model);
              // 设置异步通知地址
              alipay_request.setNotifyUrl(alipayConfig.transformUrl(payConfig.getNotifyUrl(getChannelName())));
              // 设置同步跳转地址
              alipay_request.setReturnUrl(alipayConfig.transformUrl(payConfig.getReturnUrl(getChannelName())));
              String aliResult;
              String codeUrl = "";
              JSONObject retObj = buildRetObj();
              try {
                  aliResult = client.execute(alipay_request).getBody();
                  JSONObject aliObj = JSONObject.parseObject(aliResult);
                  JSONObject aliResObj = aliObj.getJSONObject("alipay_trade_precreate_response");
                  codeUrl = aliResObj.getString("qr_code");
              } catch (AlipayApiException e) {
                  _log.error(e, "");
                  retObj.put("errDes", "下单失败[" + e.getErrMsg() + "]");
                  retObj.put(PayConstant.RETURN_PARAM_RETCODE, PayConstant.RETURN_VALUE_FAIL);
                  return retObj;
              } catch (Exception e) {
                  _log.error(e, "");
                  retObj.put("errDes", "下单失败[调取通道异常]");
                  retObj.put(PayConstant.RETURN_PARAM_RETCODE, PayConstant.RETURN_VALUE_FAIL);
                  return retObj;
              }
              _log.info("{}生成支付宝二维码:codeUrl={}", logPrefix, codeUrl);
              rpcCommonService.rpcPayOrderService.updateStatus4Ing(payOrderId, null);
      
              String codeImgUrl = payConfig.getPayUrl() + "/qrcode_img_get?url=" + codeUrl + "&widht=200&height=200";
              StringBuffer payForm = new StringBuffer();
              String toPayUrl = payConfig.getPayUrl() + "/alipay/pay_"+type+".htm";
              payForm.append("<form style=\"display: none\" action=\""+toPayUrl+"\" method=\"post\">");
              payForm.append("<input name=\"mchOrderNo\" value=\""+payOrder.getMchOrderNo()+"\" >");
              payForm.append("<input name=\"payOrderId\" value=\""+payOrder.getPayOrderId()+"\" >");
              payForm.append("<input name=\"amount\" value=\""+payOrder.getAmount()+"\" >");
              payForm.append("<input name=\"codeUrl\" value=\""+codeUrl+"\" >");
              payForm.append("<input name=\"codeImgUrl\" value=\""+codeImgUrl+"\" >");
              payForm.append("<input type=\"submit\" value=\"立即支付\" style=\"display:none\" >");
              payForm.append("</form>");
              payForm.append("<script>document.forms[0].submit();</script>");
      
              retObj.put("payOrderId", payOrderId);
              JSONObject payInfo = new JSONObject();
              payInfo.put("payUrl",payForm);
              payInfo.put("payMethod",PayConstant.PAY_METHOD_FORM_JUMP);
              retObj.put("payParams", payInfo);
              _log.info("###### 商户统一下单处理完成 ######");
              return retObj;
          }
      
      }
      
      
    4. 支付接口基类:BasePayment

      @Component
      public abstract class BasePayment extends BaseService implements PaymentInterface {
      
          @Autowired
          public RpcCommonService rpcCommonService;
      
          @Autowired
          public PayConfig payConfig;
      
          public abstract String getChannelName();
      
          
          protected JSONObject getJsonParam1(HttpServletRequest request) {
              String params = request.getParameter("params");
              if(StringUtils.isNotBlank(params)) {
                  return JSON.parseObject(params);
              }
              // 参数Map
              Map properties = request.getParameterMap();
              // 返回值Map
              JSONObject returnObject = new JSONObject();
              Iterator entries = properties.entrySet().iterator();
              Map.Entry entry;
              String name;
              String value = "";
              while (entries.hasNext()) {
                  entry = (Map.Entry) entries.next();
                  name = (String) entry.getKey();
                  Object valueObj = entry.getValue();
                  if(null == valueObj){
                      value = "";
                  }else if(valueObj instanceof String[]){
                      String[] values = (String[])valueObj;
                      for(int i=0;i<values.length;i++){
                          value = values[i] + ",";
                      }
                      value = value.substring(0, value.length()-1);
                  }else{
                      value = valueObj.toString();
                  }
                  returnObject.put(name, value);
              }
              return returnObject;
          }
      
          /**
           * 获取三方支付配置信息
           * 如果是平台账户,则使用平台对应的配置,否则使用商户自己配置的渠道
           * @param payOrder
           * @return
           */
          public String getPayParam(PayOrder payOrder) {
              String payParam = "";
              PayPassageAccount payPassageAccount = rpcCommonService.rpcPayPassageAccountService.findById(payOrder.getPassageAccountId());
              if(payPassageAccount != null && payPassageAccount.getStatus() == MchConstant.PUB_YES) {
                  payParam = payPassageAccount.getParam();
              }
              if(StringUtils.isBlank(payParam)) {
                  throw new ServiceException(RetEnum.RET_MGR_PAY_PASSAGE_ACCOUNT_NOT_EXIST);
              }
              return payParam;
          }
      
      }
      
    5. 支付测试及效果

      模拟下单:

模拟.png

支付扫码:
扫码支付.jpg

5,总结

通过约定支付渠道接入前端配置规范以及后端服务开发规范,让后续支付渠道的接入有章可循,有法可依,不仅规范了开发,也降低了渠道接入开发的难度,提高了开发效率,最终实现了任意支付渠道的灵活接入。不过也存在一些不足,比如:

  1. 常用工具方法没有统一,如加解密,远程方法调用,唯一序列生成,分布式锁,事件处理等,后续可结合SpringBoot Starter机制开发公共工具组件。
  2. 所有渠道的接入实现都在同一个工程,任何修改或者新增都要整体打包发布,给系统带来了不稳定性,后续可采用插件试开发方式,并实现动态加载渠道实现。
  3. 前后端未实现分离,无论是修改前端代码或者后端代码,每次都要整体打包发布,后续将系统前后端分离,独立开发和部署。

6,系统部分截图

运营平台系统:

运营平台.png

商户系统:

商户系统.png

代理商系统:

代理商系统.png

一些信息
路漫漫其修远兮,吾将上下而求索
码云:https://gitee.com/javacoo
QQ:164863067
作者/微信:javacoo
邮箱:xihuady@126.com
  • 1
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

javacoo

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值