Java truelicense 实现License授权许可和验证

前言

最近接到一个情况,公司平台有个授权使用的机制,之前负载这个事情的人走了,留在svn上的代码是无法通过授权的,所以让我看看什么情况

一、使用场景以及truelicense是什么

官网地址

TrueLicense是一个开源的证书管理引擎,使用场景:当项目交付给客户之后用签名来保证客户不能随意使用项目 默认校验了开始结束时间,可扩展增加mac地址校验等。 其中还有ftp的校验没有尝试,本demo详细介绍的是本地校验 license授权机制的原理: 生成密钥对,方法有很多。我们使用trueLicense来做软件产品的保护,我们主要使用它的LicenseManager类来生成证书文件、安装证书文件、验证证书文件.

二、原理

  1. 首先需要生成密钥对,方法有很多,JDK中提供的KeyTool即可生成。
  2. 授权者保留私钥,使用私钥对包含授权信息(如截止日期,MAC地址等)的license进行数字签名。
  3. 公钥交给使用者(放在验证的代码中使用),用于验证license是否符合使用条件。

三、使用Keytool命令生成密钥对

首先

Keytool 是一个Java 数据证书的管理工具 ,Keytool 将密钥(key)和证书(certificates)存在一个称为keystore的文件中 在keystore里,包含两种数据:
密钥实体(Key entity)——密钥(secret key)又或者是私钥和配对公钥(采用非对称加密)
可信任的证书实体(trusted certificate entries)——只包含公钥

在接触代码前,我们先来大概熟悉下密钥生成的流程吧
Tips: 以下命令详细的参数需要可查看参考资料.4
1、首先要用KeyTool工具来生成私匙库:(-alias别名 –validity 3650表示10年有效)

keytool -genkey -alias privatekey -keystore privateKeys.store -validity 3650

这里密码我使用123456q
注意!!!默认的密码策略是6未数字与字母,如果不遵守会报错 第五节第二点的错
在这里插入图片描述
这个时候,会在打开命令行的地方创建出一个文件,privateKeys.store()
2、然后把私匙库内的证书导出到一个文件当中:

keytool -export -alias privatekey -file certfile.cer -keystore privateKeys.store

生成certfile.cer(证书),生成公钥库后就没什么用了

3、然后再把这个证书文件导入到公匙库:

keytool -import -alias publiccert -file certfile.cer -keystore publicCerts.store

生成 publicCerts.store

privateKeys.keystore:私钥,这个我们自己留着,不能泄露给别人。
publicCerts.keystore:公钥,这个给客户用的。在我们程序里面就是用他配合license进行授权信息的校验的。
certfile.cer:这个文件没啥用,可以删掉。

最后自行将生成文件privateKeys.store、publicCerts.store拷贝出来备用。

四、实现代码 - 证书生成

maven依赖

<!--        truelicense 依赖-->
        <!-- https://mvnrepository.com/artifact/de.schlichtherle.truelicense/truelicense-core -->
        <dependency>
            <groupId>de.schlichtherle.truelicense</groupId>
            <artifactId>truelicense-core</artifactId>
            <version>1.33</version>
        </dependency>

首先从整个流程上来讲,现在这步是证书生成,证书生成需要私钥库和证书参数
在这个引擎中,公/私钥库默认是存储在项目中的。** 但是,我们实际生产环境中,都是将配置文件等脱离项目部署的**,所以我们需要重写它获取公/私钥库的地方。
CustomKeyStoreParam.java

import de.schlichtherle.license.AbstractKeyStoreParam;
import org.springframework.util.ResourceUtils;

import java.io.*;

/**
 * 自定义KeyStoreParam,用于将公私钥存储文件存放到其他磁盘位置而不是项目中。现场使用的时候公钥大部分都不会放在项目中的
 */
public class CustomKeyStoreParam extends AbstractKeyStoreParam {

    /**
     * 公钥/私钥在磁盘上的存储路径
     */
    private String storePath;
    private String alias;
    private String storePwd;
    private String keyPwd;

    public CustomKeyStoreParam(Class clazz, String resource, String alias, String storePwd, String keyPwd) {
        super(clazz, resource);
        this.storePath = resource;
        this.alias = alias;
        this.storePwd = storePwd;
        this.keyPwd = keyPwd;
    }


    @Override
    public String getAlias() {
        return alias;
    }

    @Override
    public String getStorePwd() {
        return storePwd;
    }

    @Override
    public String getKeyPwd() {
        return keyPwd;
    }

    /**
     * AbstractKeyStoreParam里面的getStream()方法默认文件是存储的项目中。
     * 用于将公私钥存储文件存放到其他磁盘位置而不是项目中
     */
    @Override
    public InputStream getStream() throws IOException {
//        return new FileInputStream(new File(storePath));
        File file = ResourceUtils.getFile(storePath);
        if (file.exists()) {
            return new FileInputStream(file);
        } else {
            throw new FileNotFoundException(storePath);
        }
    }
}

证书参数可以用配置文件配置,也可以写成类,这个方法用的就是类的方式

License.java

import cn.genm.license.dto.LicenseExtraModel;
import lombok.Data;

import java.io.Serializable;
import java.util.Date;

/**
 * License生成类需要的参数
 */
@Data
public class License implements Serializable {

    private static final long serialVersionUID = -7793154252684580872L;
    /**
     * 证书subject
     */
    private String subject;

    /**
     * 私钥别称
     */
    private String privateAlias;

    /**
     * 私钥密码(需要妥善保管,不能让使用者知道)
     */
    private String keyPass;

    /**
     * 访问私钥库的密码
     */
    private String storePass;

    /**
     * 证书生成路径
     */
    private String licensePath;

    /**
     * 私钥库存储路径
     */
    private String privateKeysStorePath;

    /**
     * 证书生效时间
     */
    private Date issuedTime = new Date();

    /**
     * 证书失效时间
     */
    private Date expiryTime;

    /**
     * 用户类型
     */
    private String consumerType = "user";

    /**
     * 用户数量
     */
    private Integer consumerAmount = 1;

    /**
     * 描述信息
     */
    private String description = "";

    /**
     * 额外的服务器硬件校验信息
     */
    private LicenseExtraModel licenseExtraModel;
}

其中的扩展参数类


/**
 * 自定义需要校验的License参数,可以增加一些额外需要校验的参数,比如项目信息,ip地址信息等等,待完善
 */
public class LicenseExtraModel {

    // 这里可以添加一些往外的自定义信息,比如我们可以增加项目验证,客户电脑sn码的验证等等

}

由于引擎本身默认只验证了有效期,当我们需要自定义一个继承于LicenseManager的自定义证书管理器。
额外的信息的校验可以加在validate()方法里

CustomLicenseManager.java

import de.schlichtherle.license.*;
import de.schlichtherle.xml.GenericCertificate;
import de.schlichtherle.xml.XMLConstants;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import java.beans.XMLDecoder;
import java.io.BufferedInputStream;
import java.io.ByteArrayInputStream;
import java.io.UnsupportedEncodingException;
import java.util.Date;

/**
 * 自定义LicenseManager,用于增加额外的信息校验(除了LicenseManager的校验,我们还可以在这个类里面添加额外的校验信息)
 */
public class CustomLicenseManager extends LicenseManager {

    private static Logger logger = LogManager.getLogger(CustomLicenseManager.class);

    public CustomLicenseManager(LicenseParam param) {
        super(param);
    }

    /**
     * 复写create方法
     */
    @Override
    protected synchronized byte[] create(LicenseContent content, LicenseNotary notary) throws Exception {
        initialize(content);
        this.validateCreate(content);
        final GenericCertificate certificate = notary.sign(content);
        return getPrivacyGuard().cert2key(certificate);
    }

    /**
     * 复写install方法,其中validate方法调用本类中的validate方法,校验IP地址、Mac地址等其他信息
     */
    @Override
    protected synchronized LicenseContent install(final byte[] key, final LicenseNotary notary) throws Exception {
        final GenericCertificate certificate = getPrivacyGuard().key2cert(key);
        notary.verify(certificate);
        final LicenseContent content = (LicenseContent) this.load(certificate.getEncoded());
        this.validate(content);
        setLicenseKey(key);
        setCertificate(certificate);

        return content;
    }

    /**
     * 复写verify方法,调用本类中的validate方法,校验IP地址、Mac地址等其他信息
     */
    @Override
    protected synchronized LicenseContent verify(final LicenseNotary notary) throws Exception {

        // Load license key from preferences,
        final byte[] key = getLicenseKey();
        if (null == key) {
            throw new NoLicenseInstalledException(getLicenseParam().getSubject());
        }

        GenericCertificate certificate = getPrivacyGuard().key2cert(key);
        notary.verify(certificate);
        final LicenseContent content = (LicenseContent) this.load(certificate.getEncoded());
        this.validate(content);
        setCertificate(certificate);

        return content;
    }

    /**
     * 校验生成证书的参数信息
     */
    protected synchronized void validateCreate(final LicenseContent content) throws LicenseContentException {
        final LicenseParam param = getLicenseParam();
        final Date now = new Date();
        final Date notBefore = content.getNotBefore();
        final Date notAfter = content.getNotAfter();
        if (null != notAfter && now.after(notAfter)) {
            throw new LicenseContentException("证书失效时间不能早于当前时间");
        }
        if (null != notBefore && null != notAfter && notAfter.before(notBefore)) {
            throw new LicenseContentException("证书生效时间不能晚于证书失效时间");
        }
        final String consumerType = content.getConsumerType();
        if (null == consumerType) {
            throw new LicenseContentException("用户类型不能为空");
        }
    }


    /**
     * 复写validate方法,用于增加我们额外的校验信息
     */
    @Override
    protected synchronized void validate(final LicenseContent content) throws LicenseContentException {
        //1. 首先调用父类的validate方法
        super.validate(content);
        //2. 然后校验自定义的License参数,去校验我们的license信息
        LicenseExtraModel expectedCheckModel = (LicenseExtraModel) content.getExtra();
        // 做我们自定义的校验
    }


    /**
     * 重写XMLDecoder解析XML
     */
    private Object load(String encoded) {
        BufferedInputStream inputStream = null;
        XMLDecoder decoder = null;
        try {
            inputStream = new BufferedInputStream(new ByteArrayInputStream(encoded.getBytes(XMLConstants.XML_CHARSET)));
            decoder = new XMLDecoder(new BufferedInputStream(inputStream, XMLConstants.DEFAULT_BUFSIZE), null, null);
            return decoder.readObject();
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        } finally {
            try {
                if (decoder != null) {
                    decoder.close();
                }
                if (inputStream != null) {
                    inputStream.close();
                }
            } catch (Exception e) {
                logger.error("XMLDecoder解析XML失败", e);
            }
        }

        return null;
    }

}

前面的所有可以说都是为了整个流程在铺垫,现在开始是真正开始生成License证书的代码

LicenseCreator.java

import cn.genm.license.dto.CustomKeyStoreParam;
import cn.genm.license.dto.CustomLicenseManager;
import cn.genm.license.model.License;
import de.schlichtherle.license.*;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import javax.security.auth.x500.X500Principal;
import java.io.File;
import java.text.MessageFormat;
import java.util.prefs.Preferences;

/**
 * License生成类 -- 用于license生成
 */
public class LicenseCreator {

    private final static X500Principal DEFAULT_HOLDER_AND_ISSUER = new X500Principal("CN=GENMER, OU=GENM, O=GENM, L=FUZHOU, ST=FUJIAN, C=CHINA");

    private static Logger logger = LogManager.getLogger(LicenseCreator.class);

    private License license;

    public LicenseCreator(License license) {
        this.license = license;
    }

    /**
     * 生成License证书
     */
    public boolean generateLicense() {
        try {
            LicenseManager licenseManager = new CustomLicenseManager(initLicenseParam());
            LicenseContent licenseContent = initLicenseContent();
            licenseManager.store(licenseContent, new File(license.getLicensePath()));
            return true;
        } catch (Exception e) {
            logger.error(MessageFormat.format("证书生成失败:{0}", license), e);
            return false;
        }
    }

    /**
     * 初始化证书生成参数
     */
    private LicenseParam initLicenseParam() {
        Preferences preferences = Preferences.userNodeForPackage(LicenseCreator.class);

        //设置对证书内容加密的秘钥
        CipherParam cipherParam = new DefaultCipherParam(license.getStorePass());

        KeyStoreParam privateStoreParam = new CustomKeyStoreParam(LicenseCreator.class
                , license.getPrivateKeysStorePath()
                , license.getPrivateAlias()
                , license.getStorePass()
                , license.getKeyPass());

        return new DefaultLicenseParam(license.getSubject()
                , preferences
                , privateStoreParam
                , cipherParam);
    }

    /**
     * 设置证书生成正文信息
     */
    private LicenseContent initLicenseContent() {
        LicenseContent licenseContent = new LicenseContent();
        licenseContent.setHolder(DEFAULT_HOLDER_AND_ISSUER);
        licenseContent.setIssuer(DEFAULT_HOLDER_AND_ISSUER);

        licenseContent.setSubject(license.getSubject());
        licenseContent.setIssued(license.getIssuedTime());
        licenseContent.setNotBefore(license.getIssuedTime());
        licenseContent.setNotAfter(license.getExpiryTime());
        licenseContent.setConsumerType(license.getConsumerType());
        licenseContent.setConsumerAmount(license.getConsumerAmount());
        licenseContent.setInfo(license.getDescription());

        //扩展校验,这里可以自定义一些额外的校验信息(也可以用json字符串保存)
        if (license.getLicenseExtraModel() != null) {
            licenseContent.setExtra(license.getLicenseExtraModel());
        }

        return licenseContent;
    }

}

五、测试 - 证书生成

环境工具类都准备好了,接下来直接开始测试,看看能否生成

 @Test
    void generateLicense() {
        // 生成license需要的一些参数
        License param = new License();
        // 证书授权主体
        param.setSubject("licenseTest");
        // 私钥别名
        param.setPrivateAlias("privateKey");
        // 私钥密码(需要妥善保管,不能让使用者知道)
        param.setKeyPass("123456q");
        // 访问私钥库的密码
        param.setStorePass("123456q");
        // 证书存储地址
        param.setLicensePath("E:\\license2\\license.lic");
        // 私钥库所在地址
        param.setPrivateKeysStorePath("E:\\license\\privateKeys.store");
        // 证书生效时间
        Calendar issueCalendar = Calendar.getInstance();
        param.setIssuedTime(issueCalendar.getTime());
        // 证书失效时间
        Calendar expiryCalendar = Calendar.getInstance();
        // 设置当前时间
        expiryCalendar.setTime(new Date());
        // 往后延长一年 = 授权一年时间
        expiryCalendar.add(Calendar.YEAR,1);
        param.setExpiryTime(expiryCalendar.getTime());
        // 用户类型
        param.setConsumerType("user");
        // 用户数量
        param.setConsumerAmount(1);
        // 描述
        param.setDescription("测试");
        LicenseCreator licenseCreator = new LicenseCreator(param);
        // 生成license
        licenseCreator.generateLicense();
    }

结果
生成的license证书

六、代码实现 - 证书安装和校验

就像证书生成,验证也需要一个专门的类

LicenseVerify.java

import cn.genm.license.dto.CustomKeyStoreParam;
import cn.genm.license.dto.CustomLicenseManager;
import de.schlichtherle.license.*;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import java.io.File;
import java.text.DateFormat;
import java.text.MessageFormat;
import java.text.SimpleDateFormat;
import java.util.prefs.Preferences;

/**
 * License校验类
 */
public class LicenseVerify {

    private static Logger logger = LogManager.getLogger(LicenseVerify.class);
    /**
     * 证书subject
     */
    private String subject;
    /**
     * 公钥别称
     */
    private String publicAlias;
    /**
     * 访问公钥库的密码
     */
    private String storePass;
    /**
     * 证书生成路径
     */
    private String licensePath;
    /**
     * 密钥库存储路径
     */
    private String publicKeysStorePath;
    /**
     * LicenseManager
     */
    private LicenseManager licenseManager;
    /**
     * 标识证书是否安装成功
     */
    private boolean installSuccess;

    public LicenseVerify(String subject, String publicAlias, String storePass, String licensePath, String publicKeysStorePath) {
        this.subject = subject;
        this.publicAlias = publicAlias;
        this.storePass = storePass;
        this.licensePath = licensePath;
        this.publicKeysStorePath = publicKeysStorePath;
    }

    /**
     * 安装License证书,读取证书相关的信息, 在bean加入容器的时候自动调用
     */
    public void installLicense() {
        try {
            Preferences preferences = Preferences.userNodeForPackage(LicenseVerify.class);

            CipherParam cipherParam = new DefaultCipherParam(storePass);

            KeyStoreParam publicStoreParam = new CustomKeyStoreParam(LicenseVerify.class,
                    publicKeysStorePath,
                    publicAlias,
                    storePass,
                    null);
            LicenseParam licenseParam = new DefaultLicenseParam(subject, preferences, publicStoreParam, cipherParam);

            licenseManager = new CustomLicenseManager(licenseParam);
            licenseManager.uninstall();
            LicenseContent licenseContent = licenseManager.install(new File(licensePath));
            DateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
            installSuccess = true;
            logger.info("------------------------------- 证书安装成功 -------------------------------");
            logger.info(MessageFormat.format("证书有效期:{0} - {1}", format.format(licenseContent.getNotBefore()), format.format(licenseContent.getNotAfter())));
        } catch (Exception e) {
            installSuccess = false;
            logger.error("------------------------------- 证书安装成功 -------------------------------");
            logger.error(e);
        }
    }

    /**
     * 卸载证书,在bean从容器移除的时候自动调用
     */
    public void unInstallLicense() {
        if (installSuccess) {
            try {
                licenseManager.uninstall();
            } catch (Exception e) {
                // ignore
            }
        }
    }

    /**
     * 校验License证书
     */
    public boolean verify() {
        try {
            LicenseContent licenseContent = licenseManager.verify();
            return true;
        } catch (Exception e) {
            return false;
        }
    }

}

我们之前说了,除了项目许多的配置文件我们一般是需要放在服务器单独的路径下的,除了公钥和私钥库,还有我们验证需要配置的一些参数

application.yml

#License相关配置
license:
 subject: licenseTest #主体 - 注意主体要与生成证书的主体一致一致,不然验证通过不了
 publicAlias: publicCert #公钥别称
 storePass: 123456q #访问公钥的密码
 licensePath: E:\license2\license.lic #license位置
 publicKeysStorePath: E:\license\publicCerts.store #公钥位置

这些参数代码获取如下

LicenseConfig.java

import cn.genm.license.server.LicenseVerify;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class LicenseConfig {

    /**
     * 证书subject
     */
    @Value("${license.subject}")
    private String subject;

    /**
     * 公钥别称
     */
    @Value("${license.publicAlias}")
    private String publicAlias;

    /**
     * 访问公钥库的密码
     */
    @Value("${license.storePass}")
    private String storePass;

    /**
     * 证书生成路径
     */
    @Value("${license.licensePath}")
    private String licensePath;

    /**
     * 密钥库存储路径
     */
    @Value("${license.publicKeysStorePath}")
    private String publicKeysStorePath;

    @Bean(initMethod = "installLicense", destroyMethod = "unInstallLicense")
    public LicenseVerify licenseVerify() {
        return new LicenseVerify(subject, publicAlias, storePass, licensePath, publicKeysStorePath);
    }

}

以上代码是读取yml的配置,以及将LicenseConfig加入Spring容器,在加入Spring容器的同时,执行licenseVerify里的安装方法

这样,程序就会在启动时,自动安装证书,校验时就可以用了

至于这个安装方法,我会单独加一篇博客整理阐述Spring里的那些能在服务启动时执行自定义操作的方法

七、测试 - 证书的安装和校验

最后的最后,验证
Tips: 记得测试的方法要创建启动类,因为licenseConfig要加入Spring容器,如果没有
LicenseApplication.java

/**
 * @author By genmer
 * @version V1.0.0
 * @date 2021/07/9 13:35
 */
@SpringBootApplication
public class LicenseApplication {
    public static void main(String[] args) {
        SpringApplication.run(LicenseApplication.class, args);
    }
}

校验代码

@SpringBootTest
@RunWith(SpringRunner.class)
public class LicenseVerifyTest {

    private LicenseVerify licenseVerify;

    @Autowired
    public void setLicenseVerify(LicenseVerify licenseVerify) {
        this.licenseVerify = licenseVerify;
    }

    @Test
    public void licenseVerify() {
       System.out.println("licese是否有效:" + licenseVerify.verify());
    }

}

结果
证书校验结果

八、执行代码期间遇到的问题

七月 08, 2021 4:01:39 下午 java.util.prefs.WindowsPreferences <init> WARNING: Could not open/create prefs root node Software\JavaSoft\Prefs at root 0x80000002. Windows RegCreateKeyEx(...) returned error code 5. Exception in thread "main" de.schlichtherle.
注册表中找不到prefs

解决方法:win+R 输入 regedit
找到这个路径,新建项HKEY_LOCAL_MACHINE\Software\JavaSoft
在这里插入图片描述
2. 密码策略要6位置数字与字母

Exception in thread "main" de.schlichtherle.license.IllegalPasswordException: The password does not match the default policy: At least six characters consisting of letters and digits!
  1. certificate is not yet valid
    注意properties里设置的时间要合理就好了,比如我今天7.8 ,我发布和有效都是7.25,有效期时间可能是这个问题的一个产生原因,一开始我认为还是密码为题,一直找

  2. 很明显,找不到这个文件,写带盘符的绝对路径就好了

Exception in thread "main" java.io.FileNotFoundException: /privateKeys.store
  1. 也很明显,不要放在中文路径下 。
Exception in thread "main" java.io.FileNotFoundException: E:\license许可测试/privateKeys.store
  1. 我在执行参考资料1的时候遇到的,虽然网上说只是个读取到结尾的标志,是正常的,可以我遇到了,并且证书不生成了,不太确定是代码问题还是异常引起的生成识别,还没解决
Exception in thread "main" java.io.EOFException
  1. 很明显,就是Subject参数在测试用例的时候的值和yml配置不一样,这样其实就算是验证失败的一种情况。
de.schlichtherle.license.LicenseContentException: exc.invalidSubject

九、参考资料

  1. javaEE防盗版-License开发
  2. 微信小程序 certificate is not yet valid
  3. TrueLicense实现license验证
  4. Keytool命令详解
  5. TrueLicense简介
  • 23
    点赞
  • 106
    收藏
    觉得还不错? 一键收藏
  • 21
    评论
Truelicense 是一个 Java 应用程序许可证管理库,它提供了一种安全,灵活和易于使用的方法来保护您的 Java 应用程序。 在 Truelicense 中使用 RSA,可以实现数字签名和验证,从而确保许可证的完整性和真实性。下面是使用 Truelicense 实现 RSA 的基本步骤: 1. 生成 RSA 密钥对 使用 JDK 自带的 keytool 工具生成 RSA 密钥对,例如: ``` keytool -genkeypair -alias mykey -keyalg RSA -keysize 2048 -validity 365 -keystore mykeystore.jks ``` 2. 配置 TruelicenseTruelicense 的配置文件中,指定密钥库文件和密钥库密码,例如: ``` license.keyStoreType = JKS license.keyStorePath = /path/to/mykeystore.jks license.keyStorePassword = mykeystorepassword license.keyAlias = mykey license.keyPassword = mykeypassword ``` 3. 签名许可证 在许可证生成时,使用私钥对许可证进行数字签名,例如: ```java // 加载许可证模板 LicenseTemplate template = LicenseTemplate.newStandard() .subject("MyApp License") .issued(new Date()) .expires(new Date(System.currentTimeMillis() + 365 * 24 * 60 * 60 * 1000L)) .build(); // 生成许可License license = License.create(template, privateKey); // 输出许可证 System.out.println(license.toString()); ``` 4. 验证许可证 在应用程序启动时,使用公钥对许可证进行验证,例如: ```java // 加载许可License license = License.load(new ByteArrayInputStream(licenseData)); // 验证许可证 if (license.verify(publicKey)) { System.out.println("License is valid"); } else { System.out.println("License is invalid"); } ``` 以上是使用 Truelicense 实现 RSA 的基本步骤,您可以根据您的具体需求进行适当的调整。
评论 21
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值